The /api/vault/unlock handler ran `bw` as `_run_bw(["unlock", req.master_password, "--raw"])`. _run_bw launches it with `asyncio.create_subprocess_exec(bw_path, *args)`, so the master password became a process argument — readable by any local user through `ps` and `/proc/<pid>/cmdline` for the lifetime of the unlock subprocess. The Bitwarden master password decrypts the entire vault, so this is a serious credential exposure on any multi-user / shared host (CWE-214). The sibling /login handler already avoids this by feeding the password on stdin; unlock was the outlier. Hand the password to `bw` through the environment instead (`--passwordenv BW_PASSWORD`), mirroring how BW_SESSION is already passed — `/proc/<pid>/environ` is readable only by the process owner, not other local users. Add regression tests pinning that the secret reaches the subprocess env and never appears in argv.
243 lines
8.1 KiB
Python
243 lines
8.1 KiB
Python
"""
|
|
vault_routes.py
|
|
|
|
Vaultwarden / Bitwarden CLI integration — config and unlock endpoints.
|
|
Stores the BW_SESSION key in data/vault.json with restrictive permissions.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import asyncio
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from fastapi import APIRouter, Request
|
|
from pydantic import BaseModel
|
|
|
|
from core.middleware import require_admin
|
|
from core.platform_compat import IS_WINDOWS, safe_chmod, which_tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VAULT_FILE = Path("data/vault.json")
|
|
|
|
|
|
def _find_bw() -> str:
|
|
"""Locate the bw binary, checking PATH and common npm-global locations.
|
|
|
|
On Windows the Bitwarden CLI shim is `bw.cmd`/`bw.exe`, resolved by
|
|
which_tool via PATHEXT.
|
|
"""
|
|
p = which_tool("bw")
|
|
if p:
|
|
return p
|
|
if IS_WINDOWS:
|
|
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
|
|
for candidate in (
|
|
os.path.join(appdata, "npm", "bw.cmd"),
|
|
os.path.join(appdata, "npm", "bw.exe"),
|
|
):
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
return "bw"
|
|
home = os.path.expanduser("~")
|
|
for candidate in (
|
|
f"{home}/.npm-global/bin/bw",
|
|
f"{home}/.nvm/versions/node/*/bin/bw",
|
|
"/usr/local/bin/bw",
|
|
"/opt/homebrew/bin/bw",
|
|
):
|
|
if "*" in candidate:
|
|
import glob
|
|
for m in glob.glob(candidate):
|
|
if os.path.isfile(m) and os.access(m, os.X_OK):
|
|
return m
|
|
elif os.path.isfile(candidate) and os.access(candidate, os.X_OK):
|
|
return candidate
|
|
return "bw" # fall back to PATH lookup (will FileNotFoundError, handled below)
|
|
|
|
|
|
def _load_config() -> dict:
|
|
if VAULT_FILE.exists():
|
|
try:
|
|
return json.loads(VAULT_FILE.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _save_config(cfg: dict):
|
|
VAULT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
VAULT_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
# POSIX: restrict the BW_SESSION store to 0o600. Windows: no-op (profile dir
|
|
# is ACL-restricted already).
|
|
safe_chmod(str(VAULT_FILE), 0o600)
|
|
|
|
|
|
async def _run_bw(args: list, session: str = None, input_text: str = None,
|
|
bw_password: str = None) -> tuple:
|
|
env = {}
|
|
env.update(os.environ)
|
|
if session:
|
|
env["BW_SESSION"] = session
|
|
# Secrets must never be passed as argv — process arguments are world-readable
|
|
# via `ps` / `/proc/<pid>/cmdline` to any local user. Hand the master password
|
|
# to `bw` through the environment instead (paired with `--passwordenv
|
|
# BW_PASSWORD` in args); /proc/<pid>/environ is readable only by the process
|
|
# owner. This mirrors how BW_SESSION is already passed above.
|
|
if bw_password is not None:
|
|
env["BW_PASSWORD"] = bw_password
|
|
bw_path = _find_bw()
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
bw_path, *args,
|
|
stdin=asyncio.subprocess.PIPE if input_text else None,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
env=env,
|
|
)
|
|
except FileNotFoundError:
|
|
return "", "bw CLI not installed (install `nodejs-bitwarden-cli` or `bitwarden-cli`)", 127
|
|
except Exception as e:
|
|
return "", f"Failed to launch bw: {e}", 1
|
|
try:
|
|
stdout, stderr = await proc.communicate(input=input_text.encode() if input_text else None)
|
|
except Exception as e:
|
|
return "", f"bw subprocess error: {e}", 1
|
|
return stdout.decode(errors="replace").strip(), stderr.decode(errors="replace").strip(), proc.returncode
|
|
|
|
|
|
class VaultConfig(BaseModel):
|
|
server_url: str = ""
|
|
email: str = ""
|
|
|
|
|
|
class VaultUnlockRequest(BaseModel):
|
|
master_password: str
|
|
|
|
|
|
class VaultLoginRequest(BaseModel):
|
|
email: str
|
|
master_password: str
|
|
|
|
|
|
def setup_vault_routes():
|
|
router = APIRouter(prefix="/api/vault", tags=["vault"])
|
|
|
|
@router.get("/config")
|
|
async def get_config(request: Request):
|
|
"""Return vault config (no sensitive fields)."""
|
|
require_admin(request)
|
|
cfg = _load_config()
|
|
return {
|
|
"server_url": cfg.get("server_url", ""),
|
|
"email": cfg.get("email", ""),
|
|
"unlocked": bool(cfg.get("session")),
|
|
"unlocked_at": cfg.get("unlocked_at", ""),
|
|
"bw_installed": await _check_bw_installed(),
|
|
}
|
|
|
|
@router.post("/config")
|
|
async def save_config(req: VaultConfig, request: Request):
|
|
"""Save vault URL + email. Runs 'bw config server' to point at Vaultwarden."""
|
|
require_admin(request)
|
|
cfg = _load_config()
|
|
cfg["server_url"] = req.server_url.strip().rstrip("/")
|
|
cfg["email"] = req.email.strip()
|
|
|
|
if cfg["server_url"]:
|
|
_, stderr, rc = await _run_bw(["config", "server", cfg["server_url"]])
|
|
if rc != 0:
|
|
return {"ok": False, "error": f"bw config failed: {stderr[:300]}"}
|
|
|
|
_save_config(cfg)
|
|
return {"ok": True}
|
|
|
|
@router.post("/login")
|
|
async def login(req: VaultLoginRequest, request: Request):
|
|
"""Log in to Vaultwarden (required once per account)."""
|
|
require_admin(request)
|
|
cfg = _load_config()
|
|
# Update email
|
|
cfg["email"] = req.email
|
|
_save_config(cfg)
|
|
|
|
stdout, stderr, rc = await _run_bw(
|
|
["login", req.email, "--raw"],
|
|
input_text=req.master_password + "\n",
|
|
)
|
|
if rc != 0:
|
|
# Already logged in is OK
|
|
if "already logged in" in stderr.lower():
|
|
return {"ok": True, "already": True}
|
|
return {"ok": False, "error": f"Login failed: {stderr[:300]}"}
|
|
# bw login --raw prints session key on success (when 2FA disabled)
|
|
if stdout:
|
|
cfg["session"] = stdout
|
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
_save_config(cfg)
|
|
return {"ok": True}
|
|
|
|
@router.post("/unlock")
|
|
async def unlock(req: VaultUnlockRequest, request: Request):
|
|
"""Unlock the vault and save the session key."""
|
|
require_admin(request)
|
|
# Pass the master password via the environment (--passwordenv), NOT as
|
|
# an argv element — argv is visible to every local user through `ps` /
|
|
# /proc/<pid>/cmdline. (The sibling /login handler already keeps the
|
|
# password off argv by feeding it on stdin.)
|
|
stdout, stderr, rc = await _run_bw(
|
|
["unlock", "--passwordenv", "BW_PASSWORD", "--raw"],
|
|
bw_password=req.master_password,
|
|
)
|
|
if rc != 0:
|
|
return {"ok": False, "error": f"Unlock failed: {stderr[:300]}"}
|
|
session = stdout.strip()
|
|
if not session:
|
|
return {"ok": False, "error": "bw returned empty session"}
|
|
cfg = _load_config()
|
|
cfg["session"] = session
|
|
cfg["unlocked_at"] = datetime.utcnow().isoformat()
|
|
_save_config(cfg)
|
|
return {"ok": True, "message": "Vault unlocked"}
|
|
|
|
@router.post("/lock")
|
|
async def lock(request: Request):
|
|
"""Lock the vault (clear session from config)."""
|
|
require_admin(request)
|
|
cfg = _load_config()
|
|
cfg.pop("session", None)
|
|
cfg.pop("unlocked_at", None)
|
|
_save_config(cfg)
|
|
# Also tell bw to lock
|
|
await _run_bw(["lock"])
|
|
return {"ok": True, "message": "Vault locked"}
|
|
|
|
@router.post("/logout")
|
|
async def logout(request: Request):
|
|
"""Log out of the Bitwarden CLI completely."""
|
|
require_admin(request)
|
|
await _run_bw(["logout"])
|
|
cfg = _load_config()
|
|
cfg.pop("session", None)
|
|
cfg.pop("email", None)
|
|
cfg.pop("unlocked_at", None)
|
|
_save_config(cfg)
|
|
return {"ok": True}
|
|
|
|
return router
|
|
|
|
|
|
async def _check_bw_installed() -> bool:
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
_find_bw(), "--version",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
await proc.communicate()
|
|
return proc.returncode == 0
|
|
except Exception:
|
|
return False
|