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:
commit
be87900794
35 files changed
+5003
No files matched your search
@@ -0,0 +1,172 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user