agent-credential-broker: fork of vaultwarden-agent-broker + OpenBao backend

Mechanical rename (package agent_credential_broker; entry points
agent-broker{,-mcp,-plugin}; env AGENT_BROKER_*; paths agent-broker;
agent-browser credential provider 'broker'; MCP server name agent-broker).

New backend 'openbao': stdlib-only HTTP client for OpenBao/Vault KV v2
(approle login or token file/env; metadata-only ref index; lazy re-login
on 401/403; token renewal). bitwarden-cli backend kept as transition.
This commit is contained in:
me committed 2026-10-14 13:27:33 -06:00
commit be87900794
35 files changed
+5003

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
"""Shared fake OpenBao server used by the offline test suites.
ThreadingHTTPServer implementing the subset of the Vault/OpenBao HTTP
API that the ``openbao`` backend exercises:
POST /v1/auth/approle/login {role_id, secret_id}
GET /v1/auth/token/lookup-self
POST /v1/auth/token/renew-self
POST /v1/auth/token/revoke-self
GET /v1/<mount>/metadata/<p>/?list=true (or LIST verb)
GET /v1/<mount>/metadata/<p>/<key>
GET /v1/<mount>/data/<p>/<key>
Every request ``(method, path)`` is recorded in ``server.calls`` so
tests can assert that unlock/list_metadata NEVER touch ``/data/*`` --
the key property of the design (the ref index is metadata-only).
Secrets live in ``server.secrets`` keyed by full path relative to the
mount, e.g. ``"agent/gitea.admin"`` -> ``{"data": {...},
"custom_metadata": {...}}``. Paths listed in ``server.forbidden``
answer 403 on ``data/*`` reads (token rejection mid-session fixture).
"""
from __future__ import annotations
import json
import threading
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
ROLE_ID = "fixture-role-id-DO-NOT-USE"
SECRET_ID = "fixture-secret-id-DO-NOT-USE"
TOKEN_VALUE = "fixture-bao-token-DO-NOT-USE"
PW = "example-password-DO-NOT-USE"
TOKEN = "example-token-DO-NOT-USE"
def fixture_secrets() -> dict:
"""KV v2 secrets under mount `secret`, prefix `agent`."""
return {
"agent/gitea.admin": {
"data": {"username": "gitea-admin", "password": PW,
"uris": ["https://git2.paginica.com"],
"notes": "bot account"},
"custom_metadata": {
"agent.ref": "gitea.admin",
"agent.adapters": "browser,http,git",
"agent.allowed_urls": "https://git2.paginica.com",
"agent.auth_type": "bearer",
"agent.confirmation": "AUTO",
"label": "gitea admin",
"uris": "https://git2.paginica.com"},
},
"agent/test.service": {
"data": {"password": TOKEN,
"uris": "https://service.example,https://alt.example"},
"custom_metadata": {
"agent.ref": "test.service",
"agent.adapters": "http,cli-profile",
"agent.allowed_urls": "https://service.example",
"agent.auth_type": "bearer",
"agent.confirmation": "AUTO",
"agent.cli_profile": "example-echo",
"label": "api token"},
},
# nested path: id is the full path relative to the prefix
"agent/infra/router": {
"data": {"username": "root", "password": PW},
"custom_metadata": {
"agent.ref": "infra.router",
"agent.adapters": "ssh",
"agent.allowed_hosts": "192.0.2.1",
"agent.confirmation": "AUTO"},
},
# no agent.ref -> invisible to the ref index (same as a BW item
# without the field)
"agent/not-for-agents": {
"data": {"username": "x", "password": PW},
"custom_metadata": {"label": "private"}},
}
class FakeBaoServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, addr, handler, *, secrets=None):
super().__init__(addr, handler)
self.secrets = dict(secrets if secrets is not None else fixture_secrets())
self.tokens: set[str] = set()
self.calls: list[tuple[str, str]] = []
self.role_id = ROLE_ID
self.secret_id = SECRET_ID
# knobs tests can tweak before a request
self.lookup_ttl = 3600 # ttl reported by lookup-self
self.lookup_renewable = True
self.renew_lease = 3600 # lease reported by renew-self
self.forbidden: set[str] = set() # data paths answering 403
self._seq = 0
def start_server(secrets=None) -> tuple[FakeBaoServer, threading.Thread, int]:
srv = FakeBaoServer(("127.0.0.1", 0), _Handler, secrets=secrets)
t = threading.Thread(target=srv.serve_forever,
kwargs={"poll_interval": 0.05}, daemon=True)
t.start()
return srv, t, srv.server_address[1]
def _children(secrets: dict, rel: str) -> list[str]:
"""Direct children of metadata dir ``rel`` (dirs end with '/')."""
keys = set()
for path in secrets:
if rel:
if not path.startswith(rel + "/"):
continue
rest = path[len(rel) + 1:]
else:
rest = path
if not rest:
continue
seg, _, tail = rest.partition("/")
keys.add(seg + "/" if tail else seg)
return sorted(keys)
class _Handler(BaseHTTPRequestHandler):
server: FakeBaoServer
def log_message(self, *a):
pass
# -- helpers -------------------------------------------------------
def _record(self, path):
self.server.calls.append((self.command, path))
def _json(self, status, obj):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _empty(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
self.end_headers()
def _body(self) -> dict:
try:
n = int(self.headers.get("Content-Length") or 0)
except ValueError:
n = 0
if n <= 0:
return {}
try:
return json.loads(self.rfile.read(n))
except Exception:
return {}
def _authed(self) -> bool:
tok = self.headers.get("X-Vault-Token")
if tok and tok in self.server.tokens:
return True
self._json(403, {"errors": ["permission denied"]})
return False
# -- routing -------------------------------------------------------
def do_GET(self):
self._dispatch("GET")
def do_POST(self):
self._dispatch("POST")
def do_LIST(self):
self._dispatch("LIST")
def do_PUT(self):
self._dispatch("PUT")
def _dispatch(self, method):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
query = urllib.parse.parse_qs(parsed.query)
self._record(path)
parts = [p for p in path.split("/") if p]
srv = self.server
# /v1/auth/approle/login
if parts == ["v1", "auth", "approle", "login"]:
if method != "POST":
return self._json(405, {"errors": ["unsupported operation"]})
creds = self._body()
if (creds.get("role_id") == srv.role_id
and creds.get("secret_id") == srv.secret_id):
srv._seq += 1
tok = f"{TOKEN_VALUE}-{srv._seq}"
srv.tokens.add(tok)
return self._json(200, {"auth": {
"client_token": tok, "renewable": True,
"lease_duration": 3600, "policies": ["agent-broker"]}})
return self._json(400, {"errors": ["invalid secret_id supplied"]})
# /v1/auth/token/{lookup-self,renew-self,revoke-self}
if parts[:3] == ["v1", "auth", "token"]:
if not self._authed():
return
tok = self.headers.get("X-Vault-Token")
op = parts[3] if len(parts) > 3 else ""
if op == "lookup-self" and method == "GET":
return self._json(200, {"data": {
"id": tok, "ttl": srv.lookup_ttl,
"renewable": srv.lookup_renewable,
"policies": ["agent-broker"]}})
if op == "renew-self" and method == "POST":
return self._json(200, {"auth": {
"client_token": tok, "renewable": True,
"lease_duration": srv.renew_lease}})
if op == "revoke-self" and method == "POST":
srv.tokens.discard(tok)
return self._empty(204)
return self._json(404, {"errors": ["unsupported path"]})
# /v1/<mount>/{metadata,data}/<rel...>
if len(parts) >= 4 and parts[0] == "v1" and parts[2] in ("metadata", "data"):
if not self._authed():
return
kind = parts[2]
rel = "/".join(parts[3:])
if method not in ("GET", "LIST"):
return self._json(405, {"errors": ["unsupported operation"]})
if method == "LIST" or "list" in query:
keys = _children(srv.secrets, rel)
if not keys and rel not in srv.secrets:
return self._json(404, {"errors": []})
return self._json(200, {"data": {"keys": keys}})
entry = srv.secrets.get(rel)
if entry is None:
return self._json(404, {"errors": []})
cm = entry.get("custom_metadata") or {}
if kind == "metadata":
return self._json(200, {"data": {
"custom_metadata": cm, "version": 1,
"created_time": "2026-01-01T00:00:00Z"}})
if rel in srv.forbidden:
return self._json(403, {"errors": ["permission denied"]})
return self._json(200, {"data": {
"data": entry.get("data") or {},
"metadata": {"custom_metadata": cm, "version": 1,
"created_time": "2026-01-01T00:00:00Z"}}})
return self._json(404, {"errors": ["unsupported path"]})
+105
View File
@@ -0,0 +1,105 @@
"""Shared fake `bw` binary source used by the offline test suites.
The fake records, per invocation: argv, the *names* of secret-channel
env vars it received (never values), and its own /proc/self/cmdline.
State lives under BITWARDENCLI_APPDATA_DIR: state.json + items.json.
"""
FAKE_BW = r'''#!/usr/bin/env python3
import json, os, sys
D = os.environ.get("BITWARDENCLI_APPDATA_DIR")
if not D:
print("missing BITWARDENCLI_APPDATA_DIR", file=sys.stderr); sys.exit(9)
os.makedirs(D, exist_ok=True)
with open(os.path.join(D, "calls.jsonl"), "a") as fh:
try:
cmdline = open("/proc/self/cmdline", "rb").read().decode("utf-8", "replace")
except OSError:
cmdline = ""
fh.write(json.dumps({
"argv": sys.argv,
"env_names": sorted(k for k in os.environ
if k.startswith(("BW_", "VAULT_", "BROKER_",
"AGENT_BROKER_"))),
"cmdline": cmdline,
}) + "\n")
args = [a for a in sys.argv[1:] if a != "--nointeraction"]
state_file = os.path.join(D, "state.json")
def load():
try:
return json.load(open(state_file))
except Exception:
return {}
def save(st):
json.dump(st, open(state_file, "w"))
def die(msg, rc=1):
print(msg, file=sys.stderr); sys.exit(rc)
st = load()
if args[:2] == ["config", "server"]:
st["server"] = args[2]; save(st)
print("Saved setting `config`."); sys.exit(0)
if args and args[0] == "status":
status = ("unauthenticated" if not st.get("logged_in")
else "unlocked" if st.get("session") else "locked")
print(json.dumps({"serverUrl": st.get("server"), "status": status}))
sys.exit(0)
if args and args[0] == "login" and "--apikey" in args:
if os.environ.get("BW_CLIENTID") == "user.fixture-id" and \
os.environ.get("BW_CLIENTSECRET") == "fixture-client-secret":
st["logged_in"] = True; save(st)
print("You are logged in!"); sys.exit(0)
die("Invalid client_id")
if args and args[0] == "unlock":
if "--check" in args:
die("Vault is locked.", 1) if not st.get("session") else \
(print("Vault is unlocked."), sys.exit(0))
pw = None
if "--passwordenv" in args:
pw = os.environ.get(args[args.index("--passwordenv") + 1])
if "--passwordfile" in args:
pw = open(args[args.index("--passwordfile") + 1]).readline().rstrip("\n")
if pw == "fixture-master-password-DO-NOT-USE":
st["session"] = "fixture-session-key-" + os.urandom(4).hex(); save(st)
print(st["session"] if "--raw" in args else "Your vault is now unlocked!")
sys.exit(0)
die("Cryptography error, The decryption operation failed")
def need_session():
if not st.get("session") or os.environ.get("BW_SESSION") != st["session"]:
die("Vault is locked.")
if args and args[0] == "sync":
need_session(); print("Syncing complete."); sys.exit(0)
if args[:2] == ["list", "items"]:
need_session()
print(open(os.path.join(D, "items.json")).read()); sys.exit(0)
if args[:2] == ["get", "item"]:
need_session()
for it in json.load(open(os.path.join(D, "items.json"))):
if it["id"] == args[2]:
print(json.dumps(it)); sys.exit(0)
die("Not found.")
if args and args[0] == "lock":
st.pop("session", None); save(st)
print("Your vault is now locked."); sys.exit(0)
if args and args[0] == "logout":
st.clear(); save(st)
print("You have logged out."); sys.exit(0)
die("unknown cmd " + " ".join(args))
'''
+597
View File
@@ -0,0 +1,597 @@
"""Synthetic security test suite for agent-broker.
Everything here runs OFFLINE against fixture credentials:
example-user / example-password-DO-NOT-USE / example-token-DO-NOT-USE
No real vault data, no network to vault.paginica.com. The daemon under
test uses the real BitwardenCliBackend driving a fake `bw` binary (see
tests/fakebw.py) that serves fixture items -- the same code path the
real daemon runs, end to end.
"""
from __future__ import annotations
import base64
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from fakebw import FAKE_BW
from agent_credential_broker import policy
from agent_credential_broker.adapters import common as ac
from agent_credential_broker.adapters.cli_profile import _validate_args
from agent_credential_broker.config import Config
from agent_credential_broker.core import BrokerCore
from agent_credential_broker.errors import BrokerError
from agent_credential_broker.sanitize import sanitize_text, sanitize_headers
from agent_credential_broker.server import _Server, call
# ---------------------------------------------------------------- fixtures
MASTER_PW = "fixture-master-password-DO-NOT-USE"
PW = "example-password-DO-NOT-USE"
TOKEN = "example-token-DO-NOT-USE"
USER = "example-user"
def fixture_items():
"""bw-JSON shaped items served by the fake `bw` (list/get item)."""
def item(i, name, username, password, uris, fields):
return {"id": f"fixture-item-{i}", "type": 1, "name": name,
"login": {"username": username, "password": password,
"totp": None,
"uris": [{"uri": u} for u in uris]},
"fields": [{"name": k, "value": v, "type": 0}
for k, v in fields.items()],
"notes": None}
return [
item(1, "gitea admin", "gitea-admin", PW,
["https://git2.paginica.com"],
{"agent.ref": "gitea.admin",
"agent.adapters": "browser,http,git",
"agent.allowed_urls": "https://git2.paginica.com",
"agent.auth_type": "bearer",
"agent.confirmation": "AUTO"}),
item(2, "vps root", "root", PW, [],
{"agent.ref": "vps.root",
"agent.adapters": "ssh",
"agent.allowed_hosts": "vps.paginica.com,192.0.2.1",
"agent.confirmation": "EVERY_USE"}),
item(3, "api token", "", TOKEN,
["https://service.example"],
{"agent.ref": "test.service",
"agent.adapters": "http,cli-profile",
"agent.allowed_urls": "https://service.example",
"agent.auth_type": "bearer",
"agent.confirmation": "AUTO",
"agent.cli_profile": "example-echo"}),
item(4, "dup a", "a", PW, [],
{"agent.ref": "dup.ref", "agent.adapters": "http",
"agent.allowed_urls": "https://service.example",
"agent.confirmation": "AUTO"}),
item(5, "dup b", "b", PW, [],
{"agent.ref": "dup.ref", "agent.adapters": "http",
"agent.allowed_urls": "https://service.example",
"agent.confirmation": "AUTO"}),
]
def _write(path: Path, text: str, mode=0o600):
path.write_text(text)
os.chmod(path, mode)
class BrokerHarness(unittest.TestCase):
"""Spins a real daemon (unix socket in a temp runtime dir) backed by
the real BitwardenCliBackend driving a fake `bw` binary with fixture
client credentials and items."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
base = Path(cls.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
_write(cred / "id.txt", "user.fixture-id")
_write(cred / "secret.token", "fixture-client-secret")
bin_dir = base / "bin"
bin_dir.mkdir()
script = bin_dir / "echo-fixture"
script.write_text(
"#!/bin/sh\n"
'echo "argv: $*"\n'
'echo "envlen: ${#EXAMPLE_TOKEN}"\n'
)
os.chmod(script, 0o755)
cls.bw = bin_dir / "bw"
cls.bw.write_text(FAKE_BW)
os.chmod(cls.bw, 0o755)
os.environ["PATH"] = f"{bin_dir}:{os.environ.get('PATH', '')}"
cls.appdata = base / "bw-appdata"
cls.appdata.mkdir(mode=0o700)
(cls.appdata / "items.json").write_text(json.dumps(fixture_items()))
cls.cfg_path = base / "broker-config.json"
cls.cfg_path.write_text(json.dumps({
"vault_url": "https://vault.paginica.com",
"client_id_file": str(cred / "id.txt"),
"client_secret_file": str(cred / "secret.token"),
"vault_backend": "bitwarden-cli",
"bw_path": str(cls.bw),
"bw_appdata_dir": str(cls.appdata),
"unlock_ttl_seconds": 3600,
"git_workdir_roots": [str(Path.home())],
}))
os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path)
os.environ["AGENT_BROKER_SOCKET"] = str(base / "rt" / "agent-broker" / "broker.sock")
os.environ["AGENT_BROKER_PROFILES_DIR"] = str(Path(__file__).resolve().parents[1] / "profiles")
cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"])
cls.socket_path.parent.mkdir(parents=True, exist_ok=True)
config = Config.load()
cls.server = _Server(str(cls.socket_path), BrokerCore(config))
os.chmod(cls.socket_path, 0o600)
threading.Thread(target=cls.server.serve_forever, kwargs={"poll_interval": 0.1},
daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.tmp.cleanup()
def call(self, method, params=None):
return call(method, params or {})
class TestLifecycle(BrokerHarness):
def test_1_starts_locked(self):
st = self.call("status")
self.assertTrue(st["locked"])
self.assertTrue(st["configured"])
def test_2_locked_denies_ops(self):
with self.assertRaises(BrokerError) as e:
self.call("list_refs")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "gitea.admin"})
self.assertEqual(e.exception.code, "BROKER_LOCKED")
def test_3_unlock_and_lock(self):
res = self.call("unlock", {"password": MASTER_PW})
self.assertTrue(res["unlocked"])
self.assertEqual(res["items"], len(fixture_items()))
st = self.call("status")
self.assertFalse(st["locked"])
self.call("lock")
self.assertTrue(self.call("status")["locked"])
class TestUnlocked(BrokerHarness):
@classmethod
def setUpClass(cls):
super().setUpClass()
call("unlock", {"password": MASTER_PW})
@classmethod
def tearDownClass(cls):
try:
call("lock")
except Exception:
pass
super().tearDownClass()
def test_refs_listed_without_values(self):
refs = {r["ref"]: r for r in self.call("list_refs")}
self.assertIn("gitea.admin", refs)
self.assertIn("vps.root", refs)
self.assertIn("test.service", refs)
blob = json.dumps(refs)
self.assertNotIn(PW, blob)
self.assertNotIn(TOKEN, blob)
def test_exact_matching(self):
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "gitea.adm"})
self.assertEqual(e.exception.code, "NOT_FOUND")
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "gitea"})
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_unknown_ref(self):
with self.assertRaises(BrokerError) as e:
self.call("http_request", {"credential_ref": "nope.ref", "method": "GET",
"url": "https://service.example/"})
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_duplicate_ref(self):
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "dup.ref"})
self.assertEqual(e.exception.code, "AMBIGUOUS_REF")
meta = [r for r in self.call("list_refs") if r["ref"] == "dup.ref"]
self.assertTrue(meta[0]["duplicate"])
def test_find_for_url(self):
self.assertEqual(self.call("find_for_url", {"url": "https://git2.paginica.com/api/v1/user"}),
["gitea.admin"])
self.assertEqual(self.call("find_for_url", {"url": "https://evil.example"}), [])
# -- adapter policy -------------------------------------------------
def test_wrong_adapter(self):
with self.assertRaises(BrokerError) as e:
self.call("browser_login", {"credential_ref": "vps.root",
"url": "https://git2.paginica.com"})
self.assertEqual(e.exception.code, "ADAPTER_DENIED")
def test_adapter_not_allowed_cli(self):
with self.assertRaises(BrokerError) as e:
self.call("cli_run", {"profile": "example-echo", "credential_ref": "gitea.admin",
"args": ["hello"]})
self.assertEqual(e.exception.code, "ADAPTER_DENIED")
# -- confirmation ----------------------------------------------------
def test_every_use_needs_ticket(self):
with self.assertRaises(BrokerError) as e:
self.call("ssh_exec", {"credential_ref": "vps.root", "host": "vps.paginica.com",
"command": "id"})
self.assertEqual(e.exception.code, "CONFIRMATION_REQUIRED")
ticket = e.exception.ticket
self.assertTrue(ticket)
info = self.call("confirm", {"ticket": ticket})
self.assertTrue(info["approved"])
try:
# 192.0.2.1 (TEST-NET-1) is in allowed_hosts and unreachable,
# so ssh exits fast without touching real infrastructure.
res = self.call("ssh_exec", {"credential_ref": "vps.root",
"host": "192.0.2.1",
"command": "id", "ticket": ticket})
# executed: sanitized result, no secret material
self.assertIn("exit_code", res)
self.assertNotIn(PW, json.dumps(res))
except BrokerError as e2:
# execution may fail on the network, but never on policy
self.assertNotIn(e2.exception.code,
("CONFIRMATION_REQUIRED", "DENIED",
"ADAPTER_DENIED", "ORIGIN_DENIED"))
def test_origin_denied_ssh(self):
with self.assertRaises(BrokerError) as e:
self.call("ssh_exec", {"credential_ref": "vps.root", "host": "evil.example",
"command": "id"})
self.assertEqual(e.exception.code, "ORIGIN_DENIED")
# -- HTTP adapter ---------------------------------------------------
def test_http_bearer_and_sanitized(self):
captured = {}
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
captured["auth"] = self.headers.get("Authorization")
body = json.dumps({"ok": True, "echo": TOKEN}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Set-Cookie", "session=abc123")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_address[1]}/api"
# service.example is allowed but the fixture has insecure_http off,
# so add a temporary item bound to the test host via a second item:
# instead we validate the auth header construction directly.
item = {"username": USER, "password": TOKEN, "fields": {
"agent.ref": "x", "agent.adapters": "http",
"agent.allowed_urls": url, "agent.auth_type": "bearer",
"agent.confirmation": "AUTO", "agent.insecure_http": "true"}}
from agent_credential_broker.adapters import http as hmod
p = policy.policy_for(item)
res = hmod.run(item, p, {"url": url, "method": "GET"}, None)
srv.shutdown()
self.assertEqual(captured["auth"], f"Bearer {TOKEN}")
self.assertEqual(res["status"], 200)
self.assertNotIn(TOKEN, res["body"])
self.assertIn("[REDACTED]", res["body"])
self.assertEqual(res["headers"]["Set-Cookie"], "[REDACTED]")
def test_http_basic_auth_header(self):
captured = {}
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
captured["auth"] = self.headers.get("Authorization")
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
srv = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=srv.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{srv.server_address[1]}/"
item = {"username": USER, "password": PW, "fields": {
"agent.ref": "x", "agent.adapters": "http",
"agent.allowed_urls": url, "agent.auth_type": "basic",
"agent.confirmation": "AUTO", "agent.insecure_http": "true"}}
from agent_credential_broker.adapters import http as hmod
res = hmod.run(item, policy.policy_for(item), {"url": url, "method": "GET"}, None)
srv.shutdown()
want = "Basic " + base64.b64encode(f"{USER}:{PW}".encode()).decode()
self.assertEqual(captured["auth"], want)
self.assertEqual(res["status"], 204)
def test_http_wrong_origin(self):
item = {"username": USER, "password": TOKEN, "fields": {
"agent.ref": "x", "agent.adapters": "http",
"agent.allowed_urls": "https://service.example",
"agent.auth_type": "bearer", "agent.confirmation": "AUTO"}}
from agent_credential_broker.adapters import http as hmod
with self.assertRaises(BrokerError) as e:
hmod.run(item, policy.policy_for(item),
{"url": "https://evil.example/", "method": "GET"}, None)
self.assertEqual(e.exception.code, "ORIGIN_DENIED")
def test_http_no_embedded_creds_via_adapter(self):
with self.assertRaises(BrokerError) as e:
self.call("git_op", {"credential_ref": "gitea.admin", "op": "clone",
"repository": "https://user:[email protected]/x/y.git",
"dest": "/tmp/nowhere"})
self.assertEqual(e.exception.code, "DENIED_INVALID_ARGUMENT")
class TestCliProfile(BrokerHarness):
@classmethod
def setUpClass(cls):
super().setUpClass()
call("unlock", {"password": MASTER_PW})
@classmethod
def tearDownClass(cls):
try:
call("lock")
except Exception:
pass
super().tearDownClass()
def test_cli_profile_executes(self):
res = self.call("cli_run", {"profile": "example-echo",
"credential_ref": "test.service",
"args": ["hello"]})
self.assertEqual(res["exit_code"], 0)
self.assertIn("argv: hello", res["stdout"])
self.assertNotIn(TOKEN, res["stdout"])
def test_unknown_profile(self):
with self.assertRaises(BrokerError) as e:
self.call("cli_run", {"profile": "unknown",
"credential_ref": "test.service", "args": ["env"]})
self.assertEqual(e.exception.code, "DENIED")
def test_malicious_args(self):
for bad in ("; env", "$(printenv)", "../../bin/sh", "a`id`b", "x|cat /etc/passwd"):
with self.assertRaises(BrokerError) as e:
self.call("cli_run", {"profile": "example-echo",
"credential_ref": "test.service",
"args": [bad]})
self.assertIn(e.exception.code, ("DENIED_INVALID_ARGUMENT",))
def test_subcommand_allowlist(self):
with self.assertRaises(BrokerError):
self.call("cli_run", {"profile": "example-echo",
"credential_ref": "test.service", "args": ["pwned"]})
class TestSanitizer(unittest.TestCase):
def test_exact_secret(self):
self.assertEqual(sanitize_text(f"data {PW} tail", [PW]), "data [REDACTED] tail")
def test_child_returns_secret(self):
res = ac.run_subprocess(["/bin/sh", "-c", f"echo {PW} >&2; echo {PW}"],
secrets=[PW])
self.assertNotIn(PW, res["stdout"])
self.assertNotIn(PW, res["stderr"])
self.assertIn("[REDACTED]", res["stdout"])
self.assertIn("[REDACTED]", res["stderr"])
def test_kv_patterns(self):
out = sanitize_text("password=abc123 token: xyz token=AAAA1111", [])
self.assertNotIn("abc123", out)
self.assertNotIn("AAAA1111", out)
def test_headers(self):
h = sanitize_headers({"Authorization": "Bearer x", "X-Ok": "1", "Set-Cookie": "a=b"})
self.assertEqual(h["Authorization"], "[REDACTED]")
self.assertEqual(h["Set-Cookie"], "[REDACTED]")
self.assertEqual(h["X-Ok"], "1")
class TestArgvIsolation(BrokerHarness):
"""Spawn broker-side children and confirm secrets are absent from
/proc/<pid>/cmdline."""
@classmethod
def setUpClass(cls):
super().setUpClass()
call("unlock", {"password": MASTER_PW})
@classmethod
def tearDownClass(cls):
try:
call("lock")
except Exception:
pass
super().tearDownClass()
def test_askpass_child_cmdline_clean(self):
"""Simulate what the ssh/git adapters do: an askpass child whose
argv contains only script + file paths -- never the secret."""
with ac.secret_dir() as d:
pwfile = d / "pw"
ac.write_secret_file(pwfile, PW)
askpass = ac.write_askpass(d, None, pwfile)
proc = subprocess.Popen([str(askpass), "Password:"], stdout=subprocess.PIPE)
cmdline = Path(f"/proc/{proc.pid}/cmdline").read_bytes()
out, _ = proc.communicate()
self.assertNotIn(PW.encode(), cmdline)
self.assertEqual(out.strip(), PW.encode()) # secret went to the tool only
def test_env_not_inherited(self):
"""clean_env must not leak ambient credential env vars."""
os.environ["DO_NOT_LEAK_ME"] = "leaky-value"
env = ac.clean_env({"INJECTED": "ok"})
self.assertNotIn("DO_NOT_LEAK_ME", env)
self.assertEqual(env["INJECTED"], "ok")
del os.environ["DO_NOT_LEAK_ME"]
def test_cli_env_isolation(self):
"""The injected env var exists ONLY in the child's environment --
the broker process env must not contain the secret afterwards."""
self.assertNotIn("EXAMPLE_TOKEN", os.environ)
res = self.call("cli_run", {"profile": "example-echo",
"credential_ref": "test.service", "args": ["hello"]})
self.assertIn("envlen: 24", res["stdout"]) # len(example-token-DO-NOT-USE)
self.assertNotIn(TOKEN, json.dumps(res))
self.assertNotIn("EXAMPLE_TOKEN", os.environ)
class TestNativePlugin(BrokerHarness):
"""The agent-browser credential.read plugin path: plugin stdin gets
a credential.resolve envelope, stdout returns the credential -- the
ONLY channel where secret material legitimately leaves the broker,
and it goes to agent-browser, not to the agent."""
@classmethod
def setUpClass(cls):
super().setUpClass()
call("unlock", {"password": MASTER_PW})
@classmethod
def tearDownClass(cls):
try:
call("lock")
except Exception:
pass
super().tearDownClass()
def _run_plugin(self, envelope):
import io
from agent_credential_broker import plugin
old_in, old_out = sys.stdin, sys.stdout
sys.stdin = io.StringIO(json.dumps(envelope))
sys.stdout = io.StringIO()
try:
rc = plugin.main()
return rc, json.loads(sys.stdout.getvalue())
finally:
sys.stdin, sys.stdout = old_in, old_out
def test_native_provider_resolve(self):
rc, resp = self._run_plugin({
"protocol": "agent-browser.plugin.v1",
"type": "credential.resolve",
"capability": "credential.read",
"request": {"itemRef": "gitea.admin",
"url": "https://git2.paginica.com/user/login"},
})
self.assertEqual(resp["protocol"], "agent-browser.plugin.v1")
self.assertTrue(resp["success"])
# the plugin is authorized to hand the secret to agent-browser
self.assertEqual(resp["credential"]["password"], PW)
self.assertEqual(resp["credential"]["username"], "gitea-admin")
def test_native_provider_wrong_origin(self):
rc, resp = self._run_plugin({
"protocol": "agent-browser.plugin.v1",
"type": "credential.resolve",
"capability": "credential.read",
"request": {"itemRef": "gitea.admin", "url": "https://evil.example/login"},
})
self.assertFalse(resp["success"])
self.assertIn("ORIGIN_DENIED", resp["error"])
def test_native_provider_adapter_denied(self):
rc, resp = self._run_plugin({
"protocol": "agent-browser.plugin.v1",
"type": "credential.resolve",
"capability": "credential.read",
"request": {"itemRef": "vps.root",
"url": "https://vps.paginica.com/login"},
})
self.assertFalse(resp["success"])
self.assertIn("ADAPTER_DENIED", resp["error"])
class TestMcpInterface(unittest.TestCase):
def test_no_retrieval_tools(self):
from agent_credential_broker import mcp_server
resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})
names = [t["name"] for t in resp["result"]["tools"]]
forbidden = ("get_password", "get_secret", "get_token", "get_private_key",
"reveal", "dump", "resolve", "read_credential")
for n in names:
for f in forbidden:
self.assertNotIn(f, n.lower(), f"forbidden tool name {n}")
for must in ("vault_status", "vault_list_refs", "credential_http_request",
"ssh_exec", "git_operation", "cli_run",
"browser_login", "browser_fill_credential",
"vault_find_for_url"):
self.assertIn(must, names)
def test_initialize(self):
from agent_credential_broker import mcp_server
resp = mcp_server._handle({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
self.assertEqual(resp["result"]["serverInfo"]["name"], "agent-broker")
class TestPolicyUnit(unittest.TestCase):
def _p(self, **fields):
f = {"agent.ref": "x", "agent.adapters": "http",
"agent.confirmation": "AUTO"}
f.update(fields)
item = {"id": "i", "name": "n", "fields": f, "uris": []}
return policy.policy_for(item), item
def test_origin_path_prefix(self):
p, _ = self._p(**{"agent.allowed_urls": "https://h.example/api"})
policy.require_url_allowed(p, "https://h.example/api/v1/users")
with self.assertRaises(BrokerError):
policy.require_url_allowed(p, "https://h.example/other")
def test_https_required(self):
p, _ = self._p(**{"agent.allowed_urls": "http://h.example"})
with self.assertRaises(BrokerError):
policy.require_url_allowed(p, "http://h.example")
p2, _ = self._p(**{"agent.allowed_urls": "http://h.example",
"agent.insecure_http": "true"})
policy.require_url_allowed(p2, "http://h.example")
def test_no_fields_no_policy(self):
self.assertIsNone(policy.policy_for({"fields": {}, "uris": []}))
if __name__ == "__main__":
unittest.main(verbosity=2)
+390
View File
@@ -0,0 +1,390 @@
"""BitwardenCliBackend tests -- run OFFLINE against a fake `bw` binary.
The fake records, per invocation: argv, the *names* of secret-channel
env vars it received (never values), and its own /proc/self/cmdline.
Tests assert that API credentials, the master password and the session
key never appear on argv/cmdline, that BW_SESSION lives only in child
env, and that the broker socket surface stays metadata-only.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from fakebw import FAKE_BW
from agent_credential_broker.config import Config
from agent_credential_broker.core import BrokerCore
from agent_credential_broker.errors import BrokerError
from agent_credential_broker.server import _Server, call
MASTER_PW = "fixture-master-password-DO-NOT-USE"
PW = "example-password-DO-NOT-USE"
TOKEN = "example-token-DO-NOT-USE"
CLIENT_ID = "user.fixture-id"
CLIENT_SECRET = "fixture-client-secret"
ITEMS = [
{"id": "item-1", "type": 1, "name": "gitea admin",
"login": {"username": "gitea-admin", "password": PW, "totp": None,
"uris": [{"uri": "https://git2.paginica.com"}]},
"fields": [
{"name": "agent.ref", "value": "gitea.admin", "type": 0},
{"name": "agent.adapters", "value": "browser,http,git", "type": 0},
{"name": "agent.allowed_urls", "value": "https://git2.paginica.com", "type": 0},
{"name": "agent.auth_type", "value": "bearer", "type": 0},
{"name": "agent.confirmation", "value": "AUTO", "type": 0},
], "notes": None},
{"id": "item-2", "type": 1, "name": "api token",
"login": {"username": "", "password": TOKEN, "totp": None,
"uris": [{"uri": "https://service.example"}]},
"fields": [
{"name": "agent.ref", "value": "test.service", "type": 0},
{"name": "agent.adapters", "value": "http,cli-profile", "type": 0},
{"name": "agent.allowed_urls", "value": "https://service.example", "type": 0},
{"name": "agent.auth_type", "value": "bearer", "type": 0},
{"name": "agent.confirmation", "value": "AUTO", "type": 0},
{"name": "agent.cli_profile", "value": "example-echo", "type": 0},
], "notes": None},
{"id": "item-3", "type": 1, "name": "not for agents",
"login": {"username": "x", "password": PW, "totp": None, "uris": []},
"fields": [], "notes": "private note"},
]
def _write(path: Path, text: str, mode=0o600):
path.write_text(text)
os.chmod(path, mode)
def read_calls(appdata: Path) -> list[dict]:
log = appdata / "calls.jsonl"
if not log.exists():
return []
return [json.loads(l) for l in log.read_text().splitlines() if l.strip()]
class CliBackendHarness(unittest.TestCase):
"""Daemon + socket + BitwardenCliBackend driving the fake `bw`."""
FAILING_BW = False
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
base = Path(cls.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
_write(cred / "id.txt", CLIENT_ID)
_write(cred / "secret.token", CLIENT_SECRET)
bin_dir = base / "bin"
bin_dir.mkdir()
cls.bw = bin_dir / "bw"
src = FAKE_BW.replace("die(\"unknown cmd", "sys.exit(1) # die(\"unknown cmd") \
if cls.FAILING_BW else FAKE_BW
cls.bw.write_text(src)
os.chmod(cls.bw, 0o755)
cls.appdata = base / "bw-appdata"
cls.appdata.mkdir(mode=0o700)
(cls.appdata / "items.json").write_text(json.dumps(ITEMS))
cls.cfg_path = base / "broker-config.json"
cls.cfg_path.write_text(json.dumps({
"vault_url": "https://vault.paginica.com",
"client_id_file": str(cred / "id.txt"),
"client_secret_file": str(cred / "secret.token"),
"vault_backend": "bitwarden-cli",
"bw_path": str(cls.bw),
"bw_appdata_dir": str(cls.appdata),
"unlock_ttl_seconds": 3600,
"git_workdir_roots": [str(Path.home())],
}))
os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path)
os.environ["AGENT_BROKER_SOCKET"] = str(base / "rt" / "agent-broker" / "broker.sock")
os.environ["AGENT_BROKER_PROFILES_DIR"] = str(Path(__file__).resolve().parents[1] / "profiles")
cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"])
cls.socket_path.parent.mkdir(parents=True, exist_ok=True)
cls.core = BrokerCore(Config.load())
cls.server = _Server(str(cls.socket_path), cls.core)
os.chmod(cls.socket_path, 0o600)
threading.Thread(target=cls.server.serve_forever,
kwargs={"poll_interval": 0.1}, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.tmp.cleanup()
def call(self, method, params=None):
return call(method, params or {})
class TestCliBackendLifecycle(CliBackendHarness):
def test_locked_then_unlock_and_lock(self):
st = self.call("status")
self.assertTrue(st["locked"])
self.assertEqual(st["backend"], "bitwarden-cli")
with self.assertRaises(BrokerError) as e:
self.call("list_refs")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
res = self.call("unlock", {"password": MASTER_PW})
self.assertTrue(res["unlocked"])
self.assertEqual(res["items"], len(ITEMS))
refs = {r["ref"]: r for r in self.call("list_refs")}
self.assertEqual(set(refs), {"gitea.admin", "test.service"})
blob = json.dumps(refs)
self.assertNotIn(PW, blob)
self.assertNotIn(TOKEN, blob)
self.call("lock")
self.assertTrue(self.call("status")["locked"])
# bw lock ran and wiped the session server-side
self.assertTrue(any(c["argv"][1] == "lock" for c in read_calls(self.appdata)))
def test_wrong_password_unlock_failed(self):
with self.assertRaises(BrokerError) as e:
self.call("unlock", {"password": "not-the-password"})
self.assertEqual(e.exception.code, "UNLOCK_FAILED")
def test_full_item_fetched_per_use(self):
self.call("unlock", {"password": MASTER_PW})
calls_before = read_calls(self.appdata)
self.assertFalse(any("item-1" in c["argv"] for c in calls_before))
cred = self.call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com/user/login"})
self.assertEqual(cred["password"], PW)
calls = read_calls(self.appdata)
get_calls = [c for c in calls if c["argv"][1:3] == ["get", "item"]]
self.assertTrue(any("item-1" in c["argv"] for c in get_calls))
# only the requested item was fetched
self.assertFalse(any("item-2" in c["argv"] for c in get_calls))
self.call("lock")
def test_metadata_state_has_no_secrets(self):
self.call("unlock", {"password": MASTER_PW})
blob = json.dumps(self.core.state.items)
for secret in (PW, TOKEN, "private note", "gitea-admin"):
self.assertNotIn(secret, blob)
# only agent.* fields survive in metadata
for meta in self.core.state.items:
for k in meta["fields"]:
self.assertTrue(k.startswith("agent."))
self.call("lock")
class TestCliBackendIsolation(CliBackendHarness):
"""Secrets travel only through child env -- never argv, never the
broker's own environment, never logs."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.call = staticmethod(call)
call("unlock", {"password": MASTER_PW})
call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com/user/login"})
# capture the session key while live, then end the session
st = json.loads((cls.appdata / "state.json").read_text())
cls.session = st.get("session")
call("lock")
def test_no_secret_on_broker_disk(self):
"""Broker-managed paths must never persist plaintext secrets.
(bw_appdata_dir is CLI-managed state and excluded on purpose --
the official client's own data.json belongs to it.)"""
base = Path(self.tmp.name)
broker_paths = [base / "rt", base / "state", self.cfg_path]
secrets = [s for s in (MASTER_PW, CLIENT_SECRET, PW, TOKEN,
self.session) if s]
for root in broker_paths:
p = Path(root)
files = [p] if p.is_file() else list(p.rglob("*"))
for f in files:
if f.is_file():
data = f.read_bytes()
for s in secrets:
self.assertNotIn(s.encode(), data,
f"{s!r} persisted in {f}")
def test_logout_via_socket(self):
self.call("unlock", {"password": MASTER_PW})
res = self.call("logout")
self.assertTrue(res["logged_out"])
calls = read_calls(self.appdata)
self.assertTrue(any(c["argv"][1] == "logout" for c in calls))
st = self.call("status")
self.assertTrue(st["locked"])
def test_no_secret_in_argv_or_cmdline(self):
for c in read_calls(self.appdata):
for secret in (CLIENT_SECRET, MASTER_PW, PW, TOKEN):
self.assertNotIn(secret, " ".join(c["argv"]))
self.assertNotIn(secret, c["cmdline"])
def test_session_key_only_in_child_env(self):
st = json.loads((self.appdata / "state.json").read_text())
session = st.get("session") # gone after lock
calls = read_calls(self.appdata)
for c in calls:
if c["argv"][1] == "login":
self.assertIn("BW_CLIENTID", c["env_names"])
self.assertIn("BW_CLIENTSECRET", c["env_names"])
if c["argv"][1] == "unlock":
self.assertNotIn("BW_SESSION", c["env_names"])
if c["argv"][1] in ("sync", "list", "get"):
self.assertIn("BW_SESSION", c["env_names"])
# session never landed on argv
if session:
for c in calls:
self.assertNotIn(session, " ".join(c["argv"]))
self.assertNotIn(session, c["cmdline"])
self.assertNotIn("BW_SESSION", os.environ)
def test_login_env_scoped_to_child(self):
self.assertNotIn("BW_CLIENTID", os.environ)
self.assertNotIn("BW_CLIENTSECRET", os.environ)
def test_isolated_appdata_dir(self):
self.assertEqual(oct(self.appdata.stat().st_mode & 0o777), "0o700")
for c in read_calls(self.appdata):
self.assertNotIn("Bitwarden CLI", c["cmdline"])
def test_config_server_called_for_vault(self):
for c in read_calls(self.appdata):
if c["argv"][1:3] == ["config", "server"]:
self.assertEqual(c["argv"][3], "https://vault.paginica.com")
return
self.fail("bw config server never invoked")
class TestCliBackendFailure(unittest.TestCase):
"""A failing `bw` must surface VAULT_BACKEND_ERROR -- never a silent
fallback to legacy crypto."""
def test_backend_failure_no_fallback(self):
with tempfile.TemporaryDirectory() as td:
base = Path(td)
cred = base / "cred"
cred.mkdir()
_write(cred / "id.txt", CLIENT_ID)
_write(cred / "secret.token", CLIENT_SECRET)
bw = base / "bw"
bw.write_text("#!/bin/sh\nexit 3\n")
os.chmod(bw, 0o755)
appdata = base / "bw-appdata"
appdata.mkdir(mode=0o700)
cfg = Config({
"client_id_file": str(cred / "id.txt"),
"client_secret_file": str(cred / "secret.token"),
"vault_backend": "bitwarden-cli",
"bw_path": str(bw),
"bw_appdata_dir": str(appdata),
})
core = BrokerCore(cfg)
with self.assertRaises(BrokerError) as e:
core.unlock(MASTER_PW)
self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR")
self.assertEqual(core.backend.name, "bitwarden-cli")
def test_child_stderr_never_carries_secret(self):
"""A buggy/compromised child echoing the master password on
stderr must still surface a sanitized error only."""
with tempfile.TemporaryDirectory() as td:
base = Path(td)
cred = base / "cred"
cred.mkdir()
_write(cred / "id.txt", CLIENT_ID)
_write(cred / "secret.token", CLIENT_SECRET)
# logged-in fake that leaks the unlock secret on stderr
bw = base / "bw"
bw.write_text(
"#!/usr/bin/env python3\n"
"import os, sys\n"
"args = sys.argv[1:]\n"
"if args[0] == 'status':\n"
" print('{\"serverUrl\": \"https://vault.paginica.com\","
" \"status\": \"locked\"}'); sys.exit(0)\n"
"if args[0] == 'unlock':\n"
" var = args[args.index('--passwordenv') + 1]\n"
" print('oops ' + os.environ.get(var, ''), file=sys.stderr)\n"
" sys.exit(1)\n"
"sys.exit(1)\n")
os.chmod(bw, 0o755)
appdata = base / "bw-appdata"
appdata.mkdir(mode=0o700)
cfg = Config({
"client_id_file": str(cred / "id.txt"),
"client_secret_file": str(cred / "secret.token"),
"vault_backend": "bitwarden-cli",
"bw_path": str(bw),
"bw_appdata_dir": str(appdata),
})
core = BrokerCore(cfg)
with self.assertRaises(BrokerError) as e:
core.unlock(MASTER_PW)
self.assertNotIn(MASTER_PW, e.exception.message)
self.assertIn("[REDACTED]", e.exception.message)
def test_unknown_backend_rejected(self):
with tempfile.TemporaryDirectory() as td:
base = Path(td)
for name in ("id.txt", "secret.token"):
_write(base / name, "x")
cfg = Config({"vault_backend": "bogus",
"client_id_file": str(base / "id.txt"),
"client_secret_file": str(base / "secret.token")})
with self.assertRaises(BrokerError) as e:
BrokerCore(cfg)
self.assertEqual(e.exception.code, "UNCONFIGURED")
def test_legacy_backend_rejected(self):
"""A stale 'legacy-crypto' config must fail loudly -- the custom
Python crypto backend was removed and must never silently be
replaced by another backend."""
with tempfile.TemporaryDirectory() as td:
base = Path(td)
for name in ("id.txt", "secret.token"):
_write(base / name, "x")
cfg = Config({"vault_backend": "legacy-crypto",
"client_id_file": str(base / "id.txt"),
"client_secret_file": str(base / "secret.token")})
with self.assertRaises(BrokerError) as e:
BrokerCore(cfg)
self.assertEqual(e.exception.code, "UNCONFIGURED")
def test_missing_bw_binary(self):
with tempfile.TemporaryDirectory() as td:
base = Path(td)
_write(base / "id.txt", CLIENT_ID)
_write(base / "secret.token", CLIENT_SECRET)
cfg = Config({"vault_backend": "bitwarden-cli",
"bw_path": str(base / "no-such-bw"),
"bw_appdata_dir": str(base / "app"),
"client_id_file": str(base / "id.txt"),
"client_secret_file": str(base / "secret.token")})
core = BrokerCore(cfg)
self.assertFalse(core.status()["configured"])
with self.assertRaises(BrokerError) as e:
core.unlock(MASTER_PW)
self.assertEqual(e.exception.code, "UNCONFIGURED")
if __name__ == "__main__":
unittest.main(verbosity=2)
+438
View File
@@ -0,0 +1,438 @@
"""OpenBaoBackend tests -- OFFLINE against a fake OpenBao HTTP server.
Mirror of test_cli_backend.py for the ``openbao`` backend. The fake
(tests/fake_bao.py) records every request path: these tests assert that
unlock + ref-index construction NEVER touch ``/data/*`` (the metadata
API is the only index source -- zero plaintext transit), that items
resolve to the same normalized shape as the bw backend, and that auth
failures map to UNLOCK_FAILED / BROKER_LOCKED.
"""
from __future__ import annotations
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import fake_bao
from agent_credential_broker.config import Config
from agent_credential_broker.core import BrokerCore
from agent_credential_broker.errors import BrokerError
from agent_credential_broker.server import _Server, call
ROLE_ID = fake_bao.ROLE_ID
SECRET_ID = fake_bao.SECRET_ID
PW = fake_bao.PW
TOKEN = fake_bao.TOKEN
def _write(path: Path, text: str, mode=0o600):
path.write_text(text)
os.chmod(path, mode)
def _cfg_dict(base: Path, port: int, **extra) -> dict:
cfg = {
"vault_backend": "openbao",
"openbao_addr": f"http://127.0.0.1:{port}",
"openbao_mount": "secret",
"openbao_prefix": "agent",
"unlock_ttl_seconds": 3600,
"git_workdir_roots": [str(Path.home())],
}
cfg.update(extra)
return cfg
class BaoHarness(unittest.TestCase):
"""Daemon + socket + OpenBaoBackend against the fake server.
AUTH_MODE selects the credential material provisioned in setUpClass:
"approle" (role_id+secret_id files) or "token_file" (0600 token).
"""
AUTH_MODE = "approle"
@classmethod
def setUpClass(cls):
# hermetic: a stray real token env var must not leak into fixtures
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
cls.tmp = tempfile.TemporaryDirectory()
base = Path(cls.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
extra = {}
if cls.AUTH_MODE == "approle":
_write(cred / "role-id.txt", ROLE_ID)
_write(cred / "secret-id.txt", SECRET_ID)
extra["openbao_role_id_file"] = str(cred / "role-id.txt")
extra["openbao_secret_id_file"] = str(cred / "secret-id.txt")
extra["openbao_token_file"] = str(cred / "absent-token.txt")
elif cls.AUTH_MODE == "token_file":
_write(cred / "bao-token.txt", fake_bao.TOKEN_VALUE + "-seed")
extra["openbao_token_file"] = str(cred / "bao-token.txt")
extra["openbao_role_id_file"] = str(cred / "absent-role.txt")
extra["openbao_secret_id_file"] = str(cred / "absent-secret.txt")
# the seed token must be a live token on the fake server
cls._seed_tokens = [fake_bao.TOKEN_VALUE + "-seed"]
cls.bao, cls.bao_thread, cls.port = fake_bao.start_server()
for tok in getattr(cls, "_seed_tokens", []):
cls.bao.tokens.add(tok)
cls.cfg_path = base / "broker-config.json"
cls.cfg_path.write_text(json.dumps(_cfg_dict(base, cls.port, **extra)))
os.environ["AGENT_BROKER_CONFIG"] = str(cls.cfg_path)
os.environ["AGENT_BROKER_SOCKET"] = str(
base / "rt" / "agent-broker" / "broker.sock")
os.environ["AGENT_BROKER_PROFILES_DIR"] = str(
Path(__file__).resolve().parents[1] / "profiles")
cls.socket_path = Path(os.environ["AGENT_BROKER_SOCKET"])
cls.socket_path.parent.mkdir(parents=True, exist_ok=True)
cls.core = BrokerCore(Config.load())
cls.server = _Server(str(cls.socket_path), cls.core)
os.chmod(cls.socket_path, 0o600)
threading.Thread(target=cls.server.serve_forever,
kwargs={"poll_interval": 0.1}, daemon=True).start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.bao.shutdown()
cls.tmp.cleanup()
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
def call(self, method, params=None):
return call(method, params or {})
def data_calls(self, since=0):
return [p for m, p in self.bao.calls[since:] if f"/data/" in p]
class TestApproleUnlock(BaoHarness):
"""unlock via AppRole: role_id+secret_id files -> auth/approle/login."""
def test_unlock_status_refs(self):
st = self.call("status")
self.assertTrue(st["locked"])
self.assertEqual(st["backend"], "openbao")
res = self.call("unlock", {"password": "ignored-by-design"})
self.assertTrue(res["unlocked"])
self.assertEqual(res["items"], 4) # incl. nested + ref-less item
refs = {r["ref"]: r for r in self.call("list_refs")}
self.assertEqual(
set(refs), {"gitea.admin", "test.service", "infra.router"})
blob = json.dumps(refs)
self.assertNotIn(PW, blob)
self.assertNotIn(TOKEN, blob)
self.call("lock")
def test_index_never_touches_data(self):
"""unlock + list_refs must use ONLY metadata endpoints."""
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
self.call("list_refs")
self.assertEqual(self.data_calls(since=n0), [])
self.assertTrue(
any("/metadata/agent" in p for m, p in self.bao.calls[n0:]))
self.call("lock")
def test_resolve_fetches_item_on_demand(self):
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
self.assertEqual(self.data_calls(since=n0), [])
cred = self.call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com/login"})
self.assertEqual(cred["password"], PW)
self.assertEqual(cred["username"], "gitea-admin")
# exactly one data read, for the requested path only
self.assertEqual(self.data_calls(since=n0),
["/v1/secret/data/agent/gitea.admin"])
self.call("lock")
def test_nested_path_id(self):
self.call("unlock", {"password": "x"})
meta = self.core.backend.get_item("infra/router")
self.assertEqual(meta["id"], "infra/router")
self.assertEqual(meta["username"], "root")
self.assertEqual(meta["fields"]["agent.ref"], "infra.router")
self.call("lock")
def test_lock_revokes_token(self):
self.call("unlock", {"password": "x"})
tok = self.core.backend._token
self.call("lock")
self.assertTrue(self.call("status")["locked"])
self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls)
self.assertNotIn(tok, self.bao.tokens) # revoked server-side
self.assertIsNone(self.core.backend._token)
def test_logout_revokes(self):
self.call("unlock", {"password": "x"})
self.call("logout")
self.assertIn(("POST", "/v1/auth/token/revoke-self"), self.bao.calls)
self.assertTrue(self.call("status")["locked"])
def test_wrong_secret_id_unlock_failed(self):
self.call("lock") # clean slate in case a prior test left state
# rotate the expected secret on the fake server: next login fails
self.bao.secret_id = "other-secret"
try:
with self.assertRaises(BrokerError) as e:
self.call("unlock", {"password": "x"})
self.assertEqual(e.exception.code, "UNLOCK_FAILED")
finally:
self.bao.secret_id = SECRET_ID
self.assertTrue(self.call("status")["locked"])
def test_403_mid_session_relog_then_locked(self):
"""A 403 on data/* triggers ONE transparent approle re-login;
a second 403 drops the token -> BROKER_LOCKED."""
n0 = len(self.bao.calls)
self.call("unlock", {"password": "x"})
logins = sum(1 for m, p in self.bao.calls[n0:]
if "approle/login" in p)
self.assertEqual(logins, 1)
self.bao.forbidden.add("agent/gitea.admin")
try:
with self.assertRaises(BrokerError) as e:
self.call("resolve", {"ref": "gitea.admin",
"url": "https://git2.paginica.com"})
self.assertEqual(e.exception.code, "BROKER_LOCKED")
logins = sum(1 for m, p in self.bao.calls[n0:]
if "approle/login" in p)
self.assertEqual(logins, 2) # exactly one re-login attempt
self.assertIsNone(self.core.backend._token)
finally:
self.bao.forbidden.discard("agent/gitea.admin")
self.call("lock")
class TestTokenFileUnlock(BaoHarness):
AUTH_MODE = "token_file"
SEED = fake_bao.TOKEN_VALUE + "-seed"
def setUp(self):
# lock() revokes the shared fixture token server-side; re-seed it
# so each test starts from a live token again.
self.bao.tokens.add(self.SEED)
def test_unlock_via_token_file(self):
res = self.call("unlock", {"password": "still-ignored"})
self.assertTrue(res["unlocked"])
# no approle login happened -- lookup-self verified the file token
self.assertFalse(any("approle/login" in p for m, p in self.bao.calls))
self.assertIn(("GET", "/v1/auth/token/lookup-self"), self.bao.calls)
self.call("lock")
def test_403_locks_without_relogin(self):
"""No approle creds -> a 403 cannot re-login; straight to locked."""
self.call("unlock", {"password": "x"})
self.bao.forbidden.add("agent/test.service")
try:
with self.assertRaises(BrokerError) as e:
self.core.backend.get_item("test.service")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
self.assertFalse(any("approle/login" in p
for m, p in self.bao.calls))
finally:
self.bao.forbidden.discard("agent/test.service")
self.call("lock")
def test_renewable_token_renews_near_expiry(self):
self.bao.lookup_ttl = 30 # < _RENEW_MARGIN_S -> renew on next call
self.bao.lookup_renewable = True
try:
self.call("unlock", {"password": "x"})
# renewal already happened inside unlock's metadata prefetch
self.assertIn(("POST", "/v1/auth/token/renew-self"),
self.bao.calls)
self.call("lock")
finally:
self.bao.lookup_ttl = 3600
class TestBackendUnit(unittest.TestCase):
"""Direct OpenBaoBackend use (no socket) for shape/error details."""
def setUp(self):
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
self.tmp = tempfile.TemporaryDirectory()
base = Path(self.tmp.name)
os.environ["XDG_RUNTIME_DIR"] = str(base / "rt")
os.environ["XDG_STATE_HOME"] = str(base / "state")
(base / "rt").mkdir()
cred = base / "cred"
cred.mkdir()
_write(cred / "role-id.txt", ROLE_ID)
_write(cred / "secret-id.txt", SECRET_ID)
self.bao, self._t, self.port = fake_bao.start_server()
cfgd = _cfg_dict(base, self.port,
openbao_role_id_file=str(cred / "role-id.txt"),
openbao_secret_id_file=str(cred / "secret-id.txt"),
openbao_token_file=str(cred / "absent-token.txt"))
self.cfgd = cfgd
from agent_credential_broker.backends.openbao import OpenBaoBackend
self.backend = OpenBaoBackend(Config(cfgd))
def tearDown(self):
self.bao.shutdown()
self.tmp.cleanup()
os.environ.pop("AGENT_BROKER_OPENBAO_TOKEN", None)
def test_get_item_full_shape(self):
self.backend.unlock("ignored")
item = self.backend.get_item("gitea.admin")
self.assertEqual(
set(item),
{"id", "type", "name", "username", "password", "totp",
"uris", "fields", "notes"})
self.assertEqual(item["id"], "gitea.admin")
self.assertEqual(item["name"], "gitea admin") # from label
self.assertEqual(item["username"], "gitea-admin")
self.assertEqual(item["password"], PW)
self.assertEqual(item["notes"], "bot account")
self.assertEqual(item["uris"], ["https://git2.paginica.com"])
f = item["fields"]
self.assertEqual(f["agent.ref"], "gitea.admin")
self.assertEqual(f["agent.confirmation"], "AUTO")
self.assertNotIn("label", f) # reserved metadata keys mapped away
self.assertNotIn("uris", f)
def test_get_item_csv_uris_and_no_label(self):
self.backend.unlock("x")
item = self.backend.get_item("test.service")
self.assertEqual(item["name"], "api token")
self.assertEqual(item["uris"],
["https://service.example", "https://alt.example"])
self.assertIsNone(item["username"])
self.backend.lock()
def test_get_item_not_found(self):
self.backend.unlock("x")
with self.assertRaises(BrokerError) as e:
self.backend.get_item("no-such-item")
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_get_item_path_traversal_rejected(self):
self.backend.unlock("x")
for bad in ("../sys", "/sys", ".."):
with self.assertRaises(BrokerError) as e:
self.backend.get_item(bad)
self.assertEqual(e.exception.code, "NOT_FOUND")
def test_metadata_projection_agent_only(self):
self.backend.unlock("x")
meta = {m["id"]: m for m in self.backend.list_metadata()}
g = meta["gitea.admin"]
self.assertEqual(g["name"], "gitea admin")
self.assertEqual(g["uris"], ["https://git2.paginica.com"])
self.assertTrue(all(k.startswith("agent.") for k in g["fields"]))
for m in meta.values():
self.assertIsNone(m["password"])
self.assertIsNone(m["username"])
self.assertIsNone(m["notes"])
def test_empty_prefix_unlocks_with_zero_items(self):
cfg = dict(self.cfgd)
cfg["openbao_prefix"] = "empty-prefix"
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
b.unlock("x") # LIST 404 -> empty index, not an error
self.assertEqual(b.list_metadata(), [])
def test_expired_token_locks(self):
self.backend.unlock("x")
self.backend._token_expires_at = time.time() - 1
with self.assertRaises(BrokerError) as e:
self.backend.get_item("gitea.admin")
self.assertEqual(e.exception.code, "BROKER_LOCKED")
self.assertIsNone(self.backend._token)
def test_unlock_via_token_env(self):
tok = fake_bao.TOKEN_VALUE + "-env"
self.bao.tokens.add(tok)
os.environ["AGENT_BROKER_OPENBAO_TOKEN"] = tok
cfg = dict(self.cfgd)
cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none")
cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "none2")
cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3")
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
self.assertTrue(b.configured())
b.unlock("x")
self.assertEqual(b._token, tok)
b.lock()
def test_unlock_via_custom_token_env(self):
tok = fake_bao.TOKEN_VALUE + "-custom"
self.bao.tokens.add(tok)
os.environ["MY_BAO_TOKEN"] = tok
try:
cfg = dict(self.cfgd)
cfg["openbao_token_env"] = "MY_BAO_TOKEN"
cfg["openbao_token_file"] = str(Path(self.tmp.name) / "none3")
cfg["openbao_role_id_file"] = str(Path(self.tmp.name) / "none")
cfg["openbao_secret_id_file"] = str(Path(self.tmp.name) / "n2")
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
b.unlock("x")
self.assertEqual(b._token, tok)
b.lock()
finally:
os.environ.pop("MY_BAO_TOKEN", None)
def test_unconfigured(self):
cfg = dict(self.cfgd)
for k in ("openbao_role_id_file", "openbao_secret_id_file",
"openbao_token_file"):
cfg[k] = str(Path(self.tmp.name) / ("absent-" + k))
from agent_credential_broker.backends.openbao import OpenBaoBackend
b = OpenBaoBackend(Config(cfg))
self.assertFalse(b.configured())
with self.assertRaises(BrokerError) as e:
b.unlock("x")
self.assertEqual(e.exception.code, "UNCONFIGURED")
def test_bad_perms_credential_file(self):
cred = Path(self.tmp.name) / "cred"
_write(cred / "secret-id.txt", SECRET_ID, mode=0o644)
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "UNCONFIGURED")
os.chmod(cred / "secret-id.txt", 0o600)
def test_server_down_backend_error(self):
self.bao.shutdown()
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "VAULT_BACKEND_ERROR")
self.assertNotIn(SECRET_ID, e.exception.message)
def test_error_never_echoes_secret_id(self):
self.bao.secret_id = "rotated" # force 400 on login
try:
with self.assertRaises(BrokerError) as e:
self.backend.unlock("x")
self.assertEqual(e.exception.code, "UNLOCK_FAILED")
self.assertNotIn(SECRET_ID, e.exception.message)
self.assertNotIn(ROLE_ID, e.exception.message)
finally:
self.bao.secret_id = SECRET_ID
if __name__ == "__main__":
unittest.main(verbosity=2)