Files
me be87900794 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.
2026-10-14 13:27:33 -06:00

173 lines
5.9 KiB
Python

"""Normalized vault item helpers shared by every vault backend.
Internal item shape (produced by all backends)::
{id, type, name, username, password, totp, uris[], fields{name:value}, notes}
``metadata_projection`` strips every secret-bearing field so the daemon
can keep a searchable ref index without retaining decrypted credentials.
``wipe_item`` drops secret values in place after a single use.
"""
from __future__ import annotations
import json
def normalize_bw_item(raw: dict) -> dict:
"""Bitwarden CLI JSON item -> internal shape."""
login = raw.get("login") or {}
fields = {}
for f in raw.get("fields") or []:
name = f.get("name")
if name is not None:
fields[name] = f.get("value") if f.get("value") is not None else ""
uris = []
for u in login.get("uris") or []:
uri = u.get("uri")
if uri:
uris.append(uri)
return {
"id": raw.get("id"),
"type": raw.get("type"),
"name": raw.get("name"),
"username": login.get("username"),
"password": login.get("password"),
"totp": login.get("totp"),
"uris": uris,
"fields": fields,
"notes": raw.get("notes"),
}
def _coerce_str(value) -> str:
"""custom_metadata/field values are strings by contract; coerce
defensively so a structured value can never break the shape."""
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value, sort_keys=True)
def _coerce_uris(raw) -> list[str]:
"""URIs may be stored as a JSON list, a CSV string or a single
string (``data.uris`` / ``custom_metadata.uris`` in KV v2)."""
if not raw:
return []
if isinstance(raw, list):
return [str(u) for u in raw if u]
if isinstance(raw, str):
s = raw.strip()
if s.startswith("["):
try:
parsed = json.loads(s)
except json.JSONDecodeError:
parsed = None
if isinstance(parsed, list):
return [str(u) for u in parsed if u]
return [u.strip() for u in s.split(",") if u.strip()]
return []
def normalize_kv_item(item_id: str, raw: dict) -> dict:
"""OpenBao KV v2 read -> internal shape (mirror of normalize_bw_item).
``raw`` is the ``data`` object of a ``<mount>/data/<path>`` read,
i.e. ``{"data": {...secret fields...}, "metadata": {...}}``.
``item_id`` is the secret path relative to the configured prefix --
for OpenBao the id IS the path, so refs cannot collide by id.
Field mapping (design doc section 4): ``data.{username,password,
totp,uris,notes}`` are the secret-bearing fields; ``custom_metadata``
carries the ``agent.*`` policy fields plus reserved ``label``/``uris``
keys. Any other ``data.*`` or ``custom_metadata`` key lands in
``fields`` exactly like a Bitwarden custom field.
"""
data = (raw.get("data") or {}) if isinstance(raw, dict) else {}
meta = (raw.get("metadata") or {}) if isinstance(raw, dict) else {}
cm = meta.get("custom_metadata") or {}
fields = {}
for k, v in cm.items():
if k in ("label", "uris"):
continue # reserved metadata keys, mapped below
fields[k] = _coerce_str(v)
for k, v in data.items():
if k in ("username", "password", "totp", "uris", "notes"):
continue
if k not in fields:
fields[k] = _coerce_str(v)
uris = _coerce_uris(data.get("uris")) or _coerce_uris(cm.get("uris"))
return {
"id": item_id,
"type": None, # KV has no item type; nothing consumes it
"name": cm.get("label") or str(item_id).rsplit("/", 1)[-1],
"username": data.get("username"),
"password": data.get("password"),
"totp": data.get("totp"),
"uris": uris,
"fields": fields,
"notes": data.get("notes"),
}
def kv_metadata_projection(item_id: str, custom_metadata: dict) -> dict:
"""Ref-index entry built from KV ``custom_metadata`` ALONE -- the
metadata API never exposes ``data/*``, so the index can be built
without a single plaintext secret transiting the broker."""
cm = custom_metadata or {}
pseudo = {
"id": item_id,
"type": None,
"name": cm.get("label") or str(item_id).rsplit("/", 1)[-1],
"uris": _coerce_uris(cm.get("uris")),
"fields": {k: _coerce_str(v) for k, v in cm.items()},
}
return metadata_projection(pseudo)
def metadata_projection(item: dict) -> dict:
"""Copy carrying only what ref resolution and policy need -- every
secret-bearing value removed. ``fields`` keeps only ``agent.*``
policy metadata."""
return {
"id": item.get("id"),
"type": item.get("type"),
"name": item.get("name"),
"username": None,
"password": None,
"totp": None,
"uris": list(item.get("uris") or []),
"fields": {k: v for k, v in (item.get("fields") or {}).items()
if k.startswith("agent.")},
"notes": None,
}
def wipe_item(item: dict) -> None:
"""Drop secret values in place after use."""
for k in ("password", "totp", "username", "notes"):
item[k] = None
fields = item.get("fields")
if isinstance(fields, dict):
for k in list(fields):
if not k.startswith("agent."):
fields[k] = None
def item_secrets(item: dict) -> list[str]:
"""All secret strings belonging to an item, for the output sanitizer."""
secrets = []
for key in ("password", "totp", "username", "notes"):
v = item.get(key)
if v and len(v) >= 4:
secrets.append(v)
for name, value in (item.get("fields") or {}).items():
if name.startswith("agent."):
continue # policy metadata is not secret
if value and len(str(value)) >= 4:
secrets.append(str(value))
return secrets