"""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 ``/data/`` 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