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.
173 lines
5.9 KiB
Python
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
|