924e290741
- bitwarden: drop dead apply_bitwarden_secrets (0 refs); encrypted cache uses atomic_write_json/entry_from_payload; fetch goes through SecretCache.lookup with an encrypted L2 reader; stale-fallback branches merged; _classify_bws_error is a rule table; token/override hooks come from the ABC. - onepassword: same substrate; _missing_binary_error, _fingerprint, _guarded dedupe repeated text/logic; _classify_op_error is a rule table. - command: drop dead parse_secret_output/get_command_secret/list_command_secrets/ apply_command_secrets (0 refs outside own test); _log helper; tests repointed to _run_helper / CommandSource.fetch.
716 lines
27 KiB
Python
716 lines
27 KiB
Python
"""Bitwarden Secrets Manager (`bws` CLI) integration.
|
|
|
|
Hermes pulls API keys from Bitwarden Secrets Manager at startup so they don't
|
|
have to live in plaintext in ``~/.hermes/.env``.
|
|
|
|
* ``bws`` is auto-installed into ``<hermes_home>/bin/bws`` on first use: one
|
|
pinned version (``_BWS_VERSION``) downloaded from the official GitHub
|
|
release and SHA-256-verified against the published checksum file.
|
|
* The one bootstrap secret is the access token in ``.env`` (``BWS_ACCESS_TOKEN``
|
|
or ``secrets.bitwarden.access_token_env``); every other key can live in BSM.
|
|
* One ``bws secret list <project_id> --output json`` call per fetch, cached
|
|
in-process and on disk for ``cache_ttl_seconds``.
|
|
* Failures NEVER block startup: a one-line warning, then continue with .env.
|
|
|
|
Subprocess-driven on purpose: one cross-platform binary is easier to
|
|
lazy-install than the ``bitwarden-sdk-secrets`` Rust-extension wheel.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import zipfile
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from agent.secret_sources._cache import (
|
|
CachedFetch as _CachedFetch,
|
|
SecretCache,
|
|
atomic_write_json,
|
|
entry_from_payload,
|
|
resolve_cache_home,
|
|
)
|
|
from agent.secret_sources.base import (
|
|
ErrorKind,
|
|
FetchResult,
|
|
SecretSource,
|
|
classify_cli_error,
|
|
coerce_float,
|
|
is_valid_env_name as _is_valid_env_name,
|
|
get_source_environment,
|
|
run_cli,
|
|
source_child_env,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Pinned upstream version — never auto-resolve "latest": release shape (asset
|
|
# names, CLI flags) may change between majors and updates must be deliberate.
|
|
_BWS_VERSION = "2.0.0"
|
|
_BWS_RELEASE_BASE = (
|
|
f"https://github.com/bitwarden/sdk-sm/releases/download/bws-v{_BWS_VERSION}"
|
|
)
|
|
_BWS_CHECKSUM_NAME = f"bws-sha256-checksums-{_BWS_VERSION}.txt"
|
|
_BWS_DOWNLOAD_TIMEOUT = 60
|
|
_BWS_RUN_TIMEOUT = 30
|
|
|
|
# Cache layout: <hermes_home>/cache/bws_cache.json holds only secret VALUES
|
|
# (never the access token) — plaintext-equivalent to .env but kept out of it so
|
|
# users editing .env don't commit BSM-sourced secrets.
|
|
_CacheKey = Tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
|
|
_DISK_CACHE_BASENAME = "bws_cache.json"
|
|
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
|
|
_ENCRYPTED_CACHE_VERSION = 1
|
|
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
|
|
|
|
|
|
def _cache_key_str(cache_key: _CacheKey) -> str:
|
|
token_fp, project_id, server_url = cache_key
|
|
return f"{token_fp}|{project_id}|{server_url}"
|
|
|
|
|
|
_STORE: SecretCache[_CacheKey] = SecretCache(_DISK_CACHE_BASENAME, key_serializer=_cache_key_str)
|
|
# Test seams: L1 dict, L2 DiskCache, and its path.
|
|
_CACHE = _STORE.memory
|
|
_DISK_CACHE = _STORE.disk
|
|
_disk_cache_path = _DISK_CACHE.path
|
|
|
|
|
|
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
|
|
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Binary discovery + lazy install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _hermes_bin_dir() -> Path:
|
|
"""Where Hermes stores its managed binaries. Profile-aware."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
return get_hermes_home() / "bin"
|
|
|
|
|
|
def find_bws(*, install_if_missing: bool = False) -> Optional[Path]:
|
|
"""Managed ``<hermes_home>/bin/bws`` first, then PATH, then optional auto-install."""
|
|
managed = _hermes_bin_dir() / _platform_binary_name()
|
|
if managed.exists() and os.access(managed, os.X_OK):
|
|
return managed
|
|
|
|
system = shutil.which("bws")
|
|
if system:
|
|
return Path(system)
|
|
|
|
if install_if_missing:
|
|
try:
|
|
return install_bws()
|
|
except Exception as exc: # noqa: BLE001 — never block startup
|
|
logger.warning("bws auto-install failed: %s", exc)
|
|
return None
|
|
return None
|
|
|
|
|
|
def _platform_binary_name() -> str:
|
|
return "bws.exe" if platform.system() == "Windows" else "bws"
|
|
|
|
|
|
def _platform_asset_name() -> str:
|
|
"""Map (uname, arch, libc) → upstream asset filename (Rust target-triple style)."""
|
|
system = platform.system()
|
|
machine = platform.machine().lower()
|
|
arch = "aarch64" if machine in ("arm64", "aarch64") else "x86_64"
|
|
|
|
if system == "Darwin": # universal binary covers Intel + Apple Silicon
|
|
return f"bws-macos-universal-{_BWS_VERSION}.zip"
|
|
if system == "Windows":
|
|
return f"bws-{arch}-pc-windows-msvc-{_BWS_VERSION}.zip"
|
|
if system == "Linux":
|
|
# glibc default; musl only if ldd says so (glibc prints to stderr, musl
|
|
# to stdout). A wrong guess surfaces as a loader error we catch.
|
|
libc = "gnu"
|
|
try:
|
|
res = subprocess.run(
|
|
["ldd", "--version"],
|
|
capture_output=True,
|
|
text=True, encoding='utf-8', errors='replace',
|
|
timeout=2,
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
if "musl" in (res.stdout + res.stderr).lower():
|
|
libc = "musl"
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
pass
|
|
return f"bws-{arch}-unknown-linux-{libc}-{_BWS_VERSION}.zip"
|
|
|
|
raise RuntimeError(
|
|
f"Unsupported platform for bws auto-install: {system} {machine}"
|
|
)
|
|
|
|
|
|
def install_bws(*, force: bool = False) -> Path:
|
|
"""Download, verify, and install the pinned ``bws`` binary; raises on any failure.
|
|
|
|
The auto-install path catches; ``hermes secrets bitwarden setup`` lets the
|
|
error propagate so the wizard can show it.
|
|
"""
|
|
bin_dir = _hermes_bin_dir()
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
target = bin_dir / _platform_binary_name()
|
|
|
|
if target.exists() and not force:
|
|
return target
|
|
|
|
asset_name = _platform_asset_name()
|
|
asset_url = f"{_BWS_RELEASE_BASE}/{asset_name}"
|
|
checksum_url = f"{_BWS_RELEASE_BASE}/{_BWS_CHECKSUM_NAME}"
|
|
|
|
with tempfile.TemporaryDirectory(prefix="hermes-bws-") as tmpdir:
|
|
tmp = Path(tmpdir)
|
|
zip_path = tmp / asset_name
|
|
checksum_path = tmp / _BWS_CHECKSUM_NAME
|
|
|
|
logger.info("Downloading %s", asset_url)
|
|
_http_download(asset_url, zip_path)
|
|
_http_download(checksum_url, checksum_path)
|
|
|
|
expected = _expected_sha256(checksum_path, asset_name)
|
|
actual = _sha256_file(zip_path)
|
|
if expected.lower() != actual.lower():
|
|
raise RuntimeError(
|
|
f"Checksum mismatch for {asset_name}: "
|
|
f"expected {expected}, got {actual}"
|
|
)
|
|
|
|
with zipfile.ZipFile(zip_path) as zf:
|
|
member = _pick_zip_member(zf, _platform_binary_name())
|
|
extracted = _safe_extract_member(zf, member, tmp)
|
|
|
|
# Stage in the final directory so the rename can't cross filesystems.
|
|
fd, staged = tempfile.mkstemp(dir=str(bin_dir), prefix=".bws_")
|
|
os.close(fd)
|
|
shutil.copy2(extracted, staged)
|
|
os.chmod(staged, 0o755)
|
|
os.replace(staged, target)
|
|
|
|
logger.info("Installed bws %s at %s", _BWS_VERSION, target)
|
|
return target
|
|
|
|
|
|
def _http_download(url: str, dest: Path) -> None:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "hermes-agent"})
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=_BWS_DOWNLOAD_TIMEOUT) as resp: # noqa: S310
|
|
with open(dest, "wb") as f:
|
|
shutil.copyfileobj(resp, f)
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(f"Failed to download {url}: {exc}") from exc
|
|
|
|
|
|
def _expected_sha256(checksum_file: Path, asset_name: str) -> str:
|
|
"""Parse standard ``sha256sum`` output (``<hex> <filename>`` per line)."""
|
|
text = checksum_file.read_text(encoding="utf-8", errors="replace")
|
|
for line in text.splitlines():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 2 and parts[-1] == asset_name:
|
|
return parts[0]
|
|
raise RuntimeError(
|
|
f"No checksum entry for {asset_name} in {checksum_file.name}"
|
|
)
|
|
|
|
|
|
def _sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(65536), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _pick_zip_member(zf: zipfile.ZipFile, binary_name: str) -> str:
|
|
"""Find the binary in the zip; tolerate a top-level dir, prefer the shortest path."""
|
|
candidates = [n for n in zf.namelist() if n.split("/")[-1] == binary_name]
|
|
if not candidates:
|
|
raise RuntimeError(
|
|
f"Could not find {binary_name} inside downloaded archive "
|
|
f"(members: {zf.namelist()[:5]}...)"
|
|
)
|
|
candidates.sort(key=len)
|
|
return candidates[0]
|
|
|
|
|
|
def _safe_extract_member(
|
|
zf: zipfile.ZipFile, member: str, dest_dir: Path
|
|
) -> Path:
|
|
"""Extract one member, refusing zip-slip (``../`` or absolute member names).
|
|
|
|
``ZipFile.extract`` joins the member onto ``dest_dir`` without verifying the
|
|
result stays inside it, so containment is checked here first.
|
|
"""
|
|
dest_root = os.path.realpath(dest_dir)
|
|
target = os.path.realpath(os.path.join(dest_root, member))
|
|
try: # commonpath raises for e.g. different Windows drives — treat as escape
|
|
contained = os.path.commonpath([dest_root, target]) == dest_root
|
|
except ValueError:
|
|
contained = False
|
|
if not contained or target == dest_root:
|
|
raise RuntimeError(
|
|
f"Refusing to extract unsafe archive member {member!r}: "
|
|
f"it escapes the extraction directory"
|
|
)
|
|
zf.extract(member, dest_root)
|
|
return Path(target)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Encrypted last-good cache (opt-in)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _token_fingerprint(token: str) -> str:
|
|
"""SHA-256 prefix used as a cache key — never logged, never displayed."""
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _b64e(raw: bytes) -> str:
|
|
return base64.b64encode(raw).decode("ascii")
|
|
|
|
|
|
def _b64d(text: str) -> bytes:
|
|
return base64.b64decode(text.encode("ascii"), validate=True)
|
|
|
|
|
|
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
|
|
"""HKDF the local cache key from the bootstrap BWS token.
|
|
|
|
cryptography is imported lazily: most CLI commands import this module while
|
|
building argparse, and eagerly mapping ``_rust.pyd`` on Windows blocks the
|
|
updater from replacing that file.
|
|
"""
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
|
|
return HKDF(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
info=_ENCRYPTED_CACHE_INFO,
|
|
).derive(access_token.encode("utf-8"))
|
|
|
|
|
|
def _write_encrypted_disk_cache(
|
|
*,
|
|
cache_key: _CacheKey,
|
|
access_token: str,
|
|
entry: _CachedFetch,
|
|
home_path: Optional[Path] = None,
|
|
) -> None:
|
|
"""Persist an AES-GCM encrypted last-good entry atomically (best-effort).
|
|
|
|
The raw token is never stored; it only derives the key. A successful write
|
|
completes migration, so the legacy plaintext cache is removed.
|
|
"""
|
|
try:
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
salt = os.urandom(16)
|
|
nonce = os.urandom(12)
|
|
serialized_key = _cache_key_str(cache_key)
|
|
key = _derive_encrypted_cache_key(access_token, salt)
|
|
plaintext = json.dumps(
|
|
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
|
|
separators=(",", ":"),
|
|
).encode("utf-8")
|
|
ciphertext = AESGCM(key).encrypt(
|
|
nonce, plaintext, serialized_key.encode("utf-8")
|
|
)
|
|
payload = {
|
|
"version": _ENCRYPTED_CACHE_VERSION,
|
|
"key": serialized_key,
|
|
"salt": _b64e(salt),
|
|
"nonce": _b64e(nonce),
|
|
"ciphertext": _b64e(ciphertext),
|
|
}
|
|
atomic_write_json(_encrypted_disk_cache_path(home_path), payload,
|
|
tmp_prefix=".bws_cache_enc_")
|
|
_STORE.disk.clear(home_path)
|
|
except Exception: # noqa: BLE001 — best-effort cache only
|
|
return
|
|
|
|
|
|
def _read_encrypted_disk_cache(
|
|
*,
|
|
cache_key: _CacheKey,
|
|
access_token: str,
|
|
max_age_seconds: float,
|
|
home_path: Optional[Path] = None,
|
|
) -> Optional[_CachedFetch]:
|
|
"""Decrypted encrypted-cache entry if it matches ``cache_key`` and is in-window."""
|
|
if max_age_seconds <= 0:
|
|
return None
|
|
try:
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
payload = json.loads(_encrypted_disk_cache_path(home_path).read_text(encoding="utf-8"))
|
|
serialized_key = _cache_key_str(cache_key)
|
|
if (not isinstance(payload, dict)
|
|
or payload.get("version") != _ENCRYPTED_CACHE_VERSION
|
|
or payload.get("key") != serialized_key):
|
|
return None
|
|
salt = _b64d(str(payload.get("salt", "")))
|
|
nonce = _b64d(str(payload.get("nonce", "")))
|
|
ciphertext = _b64d(str(payload.get("ciphertext", "")))
|
|
key = _derive_encrypted_cache_key(access_token, salt)
|
|
entry = entry_from_payload(json.loads(AESGCM(key).decrypt(
|
|
nonce, ciphertext, serialized_key.encode("utf-8")
|
|
).decode("utf-8")))
|
|
if entry is None:
|
|
return None
|
|
entry_age = time.time() - entry.fetched_at
|
|
if entry_age < 0 or entry_age > max_age_seconds:
|
|
return None
|
|
return entry
|
|
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secret fetch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def fetch_bitwarden_secrets(
|
|
*,
|
|
access_token: str,
|
|
project_id: str,
|
|
binary: Optional[Path] = None,
|
|
cache_ttl_seconds: float = 300,
|
|
use_cache: bool = True,
|
|
server_url: str = "",
|
|
home_path: Optional[Path] = None,
|
|
encrypted_cache_enabled: bool = False,
|
|
encrypted_cache_max_stale_seconds: float = 0,
|
|
) -> Tuple[Dict[str, str], List[str]]:
|
|
"""Pull the secrets for ``project_id`` from BSM → ``(secrets, warnings)``.
|
|
|
|
``server_url`` selects a region / self-hosted instance (``BWS_SERVER_URL``;
|
|
empty = bws default, US Cloud). ``cache_ttl_seconds`` governs the fresh
|
|
cache. With ``encrypted_cache_enabled`` fresh entries are written AES-GCM
|
|
encrypted instead of plaintext, and a last-good entry may be served after
|
|
NETWORK/TIMEOUT failures for up to ``encrypted_cache_max_stale_seconds`` —
|
|
independent of the fresh TTL, so ``cache_ttl_seconds: 0`` can coexist with
|
|
a break-glass offline cache.
|
|
|
|
Raises ``RuntimeError`` for fatal conditions (missing binary, auth failure,
|
|
unparseable output); the env_loader path catches, the setup wizard lets it
|
|
propagate.
|
|
"""
|
|
if not access_token:
|
|
raise RuntimeError("Bitwarden access token is empty")
|
|
if not project_id:
|
|
raise RuntimeError("Bitwarden project_id is empty")
|
|
|
|
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
|
|
|
|
def _read_encrypted(max_age: float) -> Optional[_CachedFetch]:
|
|
return _read_encrypted_disk_cache(
|
|
cache_key=cache_key, access_token=access_token,
|
|
max_age_seconds=max_age, home_path=home_path,
|
|
)
|
|
|
|
if use_cache and cache_ttl_seconds > 0:
|
|
# L2 (~5ms) vs ~380ms for `bws secret list`.
|
|
cached = _STORE.lookup(
|
|
cache_key, cache_ttl_seconds, home_path,
|
|
read_disk=(lambda: _read_encrypted(cache_ttl_seconds)) if encrypted_cache_enabled else None,
|
|
)
|
|
if cached is not None:
|
|
return cached.secrets, []
|
|
|
|
bws = binary or find_bws(install_if_missing=True)
|
|
if bws is None:
|
|
raise RuntimeError(
|
|
"bws binary not available — auto-install failed and `bws` is "
|
|
"not on PATH. Install manually from "
|
|
"https://github.com/bitwarden/sdk-sm/releases or re-run "
|
|
"`hermes secrets bitwarden setup`."
|
|
)
|
|
|
|
try:
|
|
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
|
|
except RuntimeError as exc:
|
|
# Stale fallback ONLY for transport failures — never AUTH_FAILED or a
|
|
# malformed-output INTERNAL error, where serving old secrets would mask
|
|
# a real config/credential problem. Without it a fleet sharing one BSM
|
|
# project all stops on a single network blip.
|
|
# When the encrypted cache is enabled it is the ONLY fallback consulted
|
|
# (the at-rest payload must never be plaintext). Otherwise the plain
|
|
# DiskCache is read with ttl=inf (a stale hit is the point) but only if
|
|
# the caller's real TTL > 0 — ttl<=0 means caching is opted out.
|
|
kind = _classify_bws_error(str(exc))
|
|
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
|
|
stale = label = None
|
|
if encrypted_cache_enabled:
|
|
stale = _read_encrypted(encrypted_cache_max_stale_seconds)
|
|
label = "stale ENCRYPTED disk cache"
|
|
elif cache_ttl_seconds > 0:
|
|
stale = _STORE.disk.read(cache_key, float("inf"), home_path)
|
|
label = "stale disk cache"
|
|
if stale is not None:
|
|
age = max(0.0, time.time() - stale.fetched_at)
|
|
_STORE.memory[cache_key] = stale
|
|
return stale.secrets, [
|
|
f"bws live fetch failed ({exc}); falling back to {label} ({int(age)}s old)"
|
|
]
|
|
raise
|
|
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
|
|
if use_cache:
|
|
if cache_ttl_seconds > 0:
|
|
_STORE.memory[cache_key] = entry
|
|
if encrypted_cache_enabled:
|
|
# Encryption is the storage policy; max_stale only gates outage
|
|
# reads. Never fall back to plaintext because stale fallback is off.
|
|
_write_encrypted_disk_cache(
|
|
cache_key=cache_key, access_token=access_token,
|
|
entry=entry, home_path=home_path,
|
|
)
|
|
else:
|
|
_STORE.disk.write(cache_key, entry, cache_ttl_seconds, home_path)
|
|
return secrets, warnings
|
|
|
|
|
|
def _summarize_bws_stderr(raw: str) -> str:
|
|
"""Reduce a bws (Rust color-eyre) error dump to its cause line(s).
|
|
|
|
Keeps the numbered ``0: ...`` cause lines (joined with ``; ``), drops
|
|
everything from ``Location:``/``Backtrace omitted`` on, and falls back to
|
|
the stripped raw text when the shape is unrecognized.
|
|
"""
|
|
text = raw.replace("\x1b", "").strip()
|
|
if not text:
|
|
return text
|
|
causes: List[str] = []
|
|
for line in text.splitlines():
|
|
stripped = line.strip()
|
|
if stripped.startswith(("Location:", "Backtrace omitted", "Run with ")):
|
|
break
|
|
if stripped in ("", "Error:"):
|
|
continue
|
|
stripped = re.sub(r"^\d+:\s*", "", stripped)
|
|
if stripped:
|
|
causes.append(stripped)
|
|
return "; ".join(causes) if causes else text
|
|
|
|
|
|
def _run_bws_list(
|
|
bws: Path, access_token: str, project_id: str, server_url: str = ""
|
|
) -> Tuple[Dict[str, str], List[str]]:
|
|
cmd = [str(bws), "secret", "list", project_id, "--output", "json"]
|
|
# The bws child intentionally receives the access token; a profile-local
|
|
# fetch must not inherit sibling credentials (source_child_env).
|
|
env = source_child_env()
|
|
env["BWS_ACCESS_TOKEN"] = access_token
|
|
env.setdefault("NO_COLOR", "1")
|
|
# Empty server_url keeps whatever BWS_SERVER_URL the shell already had.
|
|
if server_url:
|
|
env["BWS_SERVER_URL"] = server_url
|
|
|
|
proc = run_cli(cmd, env=env, timeout=_BWS_RUN_TIMEOUT, label="bws",
|
|
timeout_message=f"bws timed out after {_BWS_RUN_TIMEOUT}s fetching secrets")
|
|
|
|
if proc.returncode != 0:
|
|
err = _summarize_bws_stderr(proc.stderr or proc.stdout or "")
|
|
raise RuntimeError(
|
|
f"bws exited {proc.returncode}: {err[:200]}"
|
|
)
|
|
|
|
raw = proc.stdout.strip()
|
|
if not raw:
|
|
return {}, ["bws returned no output (empty project?)"]
|
|
|
|
try:
|
|
payload = json.loads(raw)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"bws returned non-JSON output: {exc}") from exc
|
|
|
|
if not isinstance(payload, list):
|
|
raise RuntimeError(
|
|
f"bws returned unexpected shape: {type(payload).__name__}"
|
|
)
|
|
|
|
secrets: Dict[str, str] = {}
|
|
warnings: List[str] = []
|
|
for item in payload:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
key = item.get("key")
|
|
value = item.get("value")
|
|
if not isinstance(key, str) or not isinstance(value, str):
|
|
continue
|
|
if not _is_valid_env_name(key):
|
|
warnings.append(
|
|
f"Skipping secret {key!r}: not a valid env-var name"
|
|
)
|
|
continue
|
|
secrets[key] = value
|
|
return secrets, warnings
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SecretSource adapter — the registry-facing wrapper around this module.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class BitwardenSource(SecretSource):
|
|
"""Bitwarden Secrets Manager as a registered **bulk** source.
|
|
|
|
``fetch()`` only fetches — precedence, overrides and the ``os.environ``
|
|
writes are the orchestrator's. Bulk: it injects every secret in the BSM
|
|
project, so explicit per-var bindings from mapped sources outrank it.
|
|
"""
|
|
|
|
name = "bitwarden"
|
|
label = "Bitwarden Secrets Manager"
|
|
shape = "bulk"
|
|
scheme = "bws"
|
|
token_env_key = "access_token_env"
|
|
default_token_env = "BWS_ACCESS_TOKEN"
|
|
# override_existing defaults True: the point of BSM is centralized rotation
|
|
# — a stale .env line must not have the final say.
|
|
override_existing_default = True
|
|
|
|
def config_schema(self) -> dict:
|
|
return {
|
|
"enabled": {"description": "Master switch", "default": False},
|
|
"access_token_env": {
|
|
"description": "Env var holding the machine-account access token",
|
|
"default": "BWS_ACCESS_TOKEN",
|
|
},
|
|
"project_id": {"description": "BSM project UUID", "default": ""},
|
|
"cache_ttl_seconds": {
|
|
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
|
|
"default": 300,
|
|
},
|
|
"encrypted_cache": {
|
|
"description": "Encrypted last-good cache for network/timeout fallback",
|
|
"default": {"enabled": False, "max_stale_seconds": 0},
|
|
},
|
|
"override_existing": {"description": "BSM values overwrite .env/shell values", "default": True},
|
|
"auto_install": {"description": "Auto-download the pinned bws binary", "default": True},
|
|
"server_url": {"description": "Region / self-hosted endpoint (empty = US Cloud)", "default": ""},
|
|
}
|
|
|
|
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
|
|
cfg = cfg if isinstance(cfg, dict) else {}
|
|
result = FetchResult()
|
|
|
|
access_token_env = self.token_env(cfg)
|
|
access_token = get_source_environment().get(access_token_env, "").strip()
|
|
if not access_token:
|
|
return result.fail(
|
|
f"secrets.bitwarden.enabled is true but {access_token_env} is "
|
|
"not set. Run `hermes secrets bitwarden setup`.",
|
|
ErrorKind.NOT_CONFIGURED,
|
|
)
|
|
|
|
project_id = str(cfg.get("project_id") or "")
|
|
if not project_id:
|
|
return result.fail(
|
|
"secrets.bitwarden.project_id is empty. "
|
|
"Run `hermes secrets bitwarden setup`.",
|
|
ErrorKind.NOT_CONFIGURED,
|
|
)
|
|
|
|
binary = find_bws(install_if_missing=bool(cfg.get("auto_install", True)))
|
|
result.binary_path = binary
|
|
if binary is None:
|
|
return result.fail(
|
|
"bws binary not available and auto-install is disabled. "
|
|
"Run `hermes secrets bitwarden setup` to install.",
|
|
ErrorKind.BINARY_MISSING,
|
|
)
|
|
|
|
encrypted_cfg = cfg.get("encrypted_cache")
|
|
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
|
|
|
|
try:
|
|
secrets, warnings = fetch_bitwarden_secrets(
|
|
access_token=access_token,
|
|
project_id=project_id,
|
|
binary=binary,
|
|
cache_ttl_seconds=coerce_float(cfg.get("cache_ttl_seconds", 300), 300.0),
|
|
server_url=str(cfg.get("server_url", "") or "").strip(),
|
|
home_path=home_path,
|
|
encrypted_cache_enabled=bool(encrypted_cfg.get("enabled", False)),
|
|
encrypted_cache_max_stale_seconds=coerce_float(
|
|
encrypted_cfg.get("max_stale_seconds", 0), 0.0),
|
|
)
|
|
except RuntimeError as exc:
|
|
result.fail(str(exc), _classify_bws_error(str(exc)))
|
|
if result.error_kind == ErrorKind.AUTH_FAILED:
|
|
# Say what the raw OAuth reject means for the user first.
|
|
result.error = (
|
|
"Bitwarden rejected the machine-account access token "
|
|
f"({access_token_env}) — it was likely revoked, expired, "
|
|
f"or belongs to another region. ({result.error})"
|
|
)
|
|
return result
|
|
|
|
result.secrets = secrets
|
|
result.warnings.extend(warnings)
|
|
return result
|
|
|
|
def remediation(self, kind, cfg: dict) -> str:
|
|
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
|
|
return (
|
|
"Run `hermes secrets bitwarden token` to paste a fresh access "
|
|
"token (create one in the Bitwarden web app: Secrets Manager → "
|
|
"Machine accounts → Access tokens). Wrong region? Re-run "
|
|
"`hermes secrets bitwarden setup` and pick EU/self-hosted."
|
|
)
|
|
return super().remediation(kind, cfg)
|
|
|
|
|
|
# First matching rule wins. The BSM identity endpoint rejects a revoked /
|
|
# expired machine-account token with an OAuth-style
|
|
# `[400 Bad Request] {"error":"invalid_client"}`, hence those AUTH tokens.
|
|
_BWS_ERROR_RULES = (
|
|
(ErrorKind.TIMEOUT, ("timed out",)),
|
|
(ErrorKind.BINARY_MISSING, ("binary not available", "failed to invoke")),
|
|
(ErrorKind.AUTH_FAILED, ("unauthorized", "invalid token", "access token", "401", "403",
|
|
"invalid_client", "invalid_grant", "400 bad request")),
|
|
(ErrorKind.NETWORK, ("network", "connection", "resolve", "download", "dns")),
|
|
)
|
|
|
|
|
|
def _classify_bws_error(message: str) -> ErrorKind:
|
|
return classify_cli_error(message, _BWS_ERROR_RULES)
|
|
|
|
|
|
def clear_caches(home_path: Optional[Path] = None) -> None:
|
|
"""Drop in-process AND disk caches (plaintext and encrypted).
|
|
|
|
Used after a token rotation so the next startup fetches fresh instead of
|
|
serving a pull cached under the old token's fingerprint.
|
|
"""
|
|
_STORE.clear(home_path)
|
|
try:
|
|
_encrypted_disk_cache_path(home_path).unlink()
|
|
except (FileNotFoundError, OSError):
|
|
pass
|
|
|
|
|
|
_reset_cache_for_tests = clear_caches
|