fix: credential lifecycle safety -- update_item, duplicate prevention, tests

CRITICAL FIX: The --enable-2fa flow was creating duplicate BW items
instead of updating in place, which led to ambiguous item resolution
and data integrity issues. This was a severe failure in core
credential lifecycle operations.

Changes:
- bw_helper.py: Complete rewrite with safety guarantees
  - update_item(): modifies existing item in place by ID, preserves
    all fields not being updated
  - create_item(): refuses to create duplicates (raises if item exists)
  - get_item_id(): resolves name to ID, raises on ambiguous matches
  - get_item(): returns full item JSON
  - NO delete_item method exists by design -- credential deletion
    is a manual operation only
- provision-agent.py: --enable-2fa now uses update_item() instead
  of create_item() to add TOTP to existing credentials
- Dockerfile: non-root user with correct BW state directory ownership
- docker-compose.yml: bind mount for BW state (proper permissions)
- test_bw_helper.py: 10 tests covering full lifecycle
  (create, read, duplicate rejection, update password, update TOTP,
  field preservation, no-delete verification)
  Tests 1-4 verified passing against live Vaultwarden instance.
- requirements.txt: added pytest

💘 Generated with Crush

Assisted-by: Crush:glm-5.2
This commit is contained in:
TSYS Group COO
2026-08-13 14:47:57 -05:00
parent 04ece5234a
commit f638105614
8 changed files with 442 additions and 118 deletions
+158 -60
View File
@@ -1,21 +1,29 @@
#!/usr/bin/env python3
"""
bw-helper.py Bitwarden CLI wrapper for agent identity provisioning.
bw_helper.py -- Bitwarden CLI wrapper for agent identity provisioning.
Provides a clean Python interface to the `bw` CLI for:
- Password generation
- Item creation/retrieval in collections
- Item creation/retrieval/updating in collections
- TOTP code generation
- Session management
All BW commands run via subprocess. The BW session is established once
and reused across calls.
SAFETY RULES:
- This module NEVER deletes items. There is no delete_item method.
- create_item() refuses to create duplicates.
- update_item() modifies an existing item in place by ID.
- get_item_id() is the canonical way to resolve an item -- it uses the
BW search API and raises on ambiguity (multiple matches).
"""
import json
import os
import subprocess
import sys
import tempfile
from typing import Optional
@@ -48,12 +56,25 @@ class BitwardenHelper:
)
return result.stdout.strip() if capture else ""
def _encode(self, data: dict) -> str:
"""Encode a dict to base64 for bw CLI input."""
encoded = json.dumps(data)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
return result.stdout.strip()
def login(self) -> None:
"""Authenticate via API key and unlock the vault.
Configures the BW server URL (for self-hosted instances), logs in
via API key, and unlocks the vault. API key auth does not require
TOTP the key itself is obtained from an authenticated session.
TOTP -- the key itself is obtained from an authenticated session.
"""
env = os.environ.copy()
env["BW_CLIENTID"] = self.client_id
@@ -75,7 +96,6 @@ class BitwardenHelper:
raise RuntimeError(f"BW login failed: {result.stderr.strip()}")
# Unlock via password file (more reliable than stdin with native binary)
import tempfile
with tempfile.NamedTemporaryFile(mode="w", suffix=".pw", delete=False) as pw_file:
pw_file.write(self.password)
pw_file_path = pw_file.name
@@ -88,7 +108,7 @@ class BitwardenHelper:
os.unlink(pw_file_path)
if not self.session:
raise RuntimeError("BW unlock failed no session token returned")
raise RuntimeError("BW unlock failed -- no session token returned")
def generate_password(self, length: int = 32) -> str:
"""Generate a strong password."""
@@ -98,6 +118,45 @@ class BitwardenHelper:
"""Get the current TOTP code for a Bitwarden item."""
return self._run_bw(["get", "totp", item_name])
# -------------------------------------------------------------------
# Item ID resolution -- the safe way to reference items
# -------------------------------------------------------------------
def get_item_id(self, name: str) -> Optional[str]:
"""Resolve an item name to its BW ID.
Returns the item ID if exactly one match exists, None if no match,
and raises RuntimeError if multiple items share the name (ambiguous).
"""
try:
output = self._run_bw(["list", "items", "--search", name])
items = json.loads(output)
# Filter to exact name matches (bw search is fuzzy)
matches = [i for i in items if i.get("name") == name]
if len(matches) == 0:
return None
if len(matches) > 1:
ids = ", ".join(m["id"] for m in matches)
raise RuntimeError(
f"Multiple BW items named '{name}': {ids}. "
f"This is a data integrity issue -- resolve manually."
)
return matches[0]["id"]
except json.JSONDecodeError:
return None
def get_item(self, name: str) -> Optional[dict]:
"""Get the full item JSON by name. Returns None if not found."""
item_id = self.get_item_id(name)
if not item_id:
return None
output = self._run_bw(["get", "item", item_id])
return json.loads(output)
# -------------------------------------------------------------------
# Create / Update -- never duplicate, never delete
# -------------------------------------------------------------------
def create_item(
self,
name: str,
@@ -110,8 +169,20 @@ class BitwardenHelper:
) -> str:
"""Create a login item in a Bitwarden collection.
Raises RuntimeError if an item with this name already exists.
Use update_item() to modify an existing item.
Returns the item ID.
"""
# SAFEGUARD: refuse to create duplicates
existing_id = self.get_item_id(name)
if existing_id:
raise RuntimeError(
f"Item '{name}' already exists (id={existing_id}). "
f"Use update_item() to modify it. "
f"This safeguard prevents credential duplication."
)
item = {
"type": 1, # LOGIN
"name": name,
@@ -120,41 +191,102 @@ class BitwardenHelper:
"password": password,
"uris": [{"uri": u, "match": None} for u in uris],
},
"collectionIds": [], # resolved by collection_name below
"collectionIds": [],
}
if totp_secret:
item["login"]["totp"] = totp_secret
fields = []
if custom_fields:
for key, value in custom_fields.items():
fields.append({"name": key, "value": value, "type": 0})
if fields:
item["fields"] = fields
item["fields"] = [
{"name": k, "value": v, "type": 0}
for k, v in custom_fields.items()
]
# Resolve collection ID
collection_id = self._get_collection_id(collection_name)
if collection_id:
item["collectionIds"] = [collection_id]
# Create via BW CLI
encoded = json.dumps(item)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
encoded_item = result.stdout.strip()
encoded_item = self._encode(item)
output = self._run_bw(["create", "item", encoded_item])
created = json.loads(output)
return created.get("id", "")
def update_item(
self,
name: str,
password: Optional[str] = None,
totp_secret: Optional[str] = None,
uris: Optional[list[str]] = None,
custom_fields: Optional[dict[str, str]] = None,
) -> str:
"""Update an existing item in place by name.
Only the provided fields are updated; others remain unchanged.
If the item does not exist, raises RuntimeError.
Returns the item ID.
"""
item_id = self.get_item_id(name)
if not item_id:
raise RuntimeError(
f"Cannot update: item '{name}' not found. "
f"Use create_item() to create it first."
)
# Fetch the current item to preserve existing fields
current = json.loads(self._run_bw(["get", "item", item_id]))
# Apply updates only to provided fields
if password is not None:
current["login"]["password"] = password
if totp_secret is not None:
current["login"]["totp"] = totp_secret
if uris is not None:
current["login"]["uris"] = [{"uri": u, "match": None} for u in uris]
if custom_fields is not None:
current["fields"] = [
{"name": k, "value": v, "type": 0}
for k, v in custom_fields.items()
]
encoded_item = self._encode(current)
output = self._run_bw(["edit", "item", item_id, encoded_item])
updated = json.loads(output)
return updated.get("id", item_id)
# -------------------------------------------------------------------
# Read helpers
# -------------------------------------------------------------------
def item_exists(self, name: str) -> bool:
"""Check if a Bitwarden item with this name already exists."""
return self.get_item_id(name) is not None
def get_item_password(self, name: str) -> str:
"""Get the password field from a Bitwarden item."""
item_id = self.get_item_id(name)
if not item_id:
raise RuntimeError(f"Item '{name}' not found")
return self._run_bw(["get", "password", item_id])
def get_item_uri(self, name: str) -> str:
"""Get the URI from a Bitwarden item."""
item = self.get_item(name)
if not item:
return ""
uris = item.get("login", {}).get("uris", [])
return uris[0]["uri"] if uris else ""
def list_items(self) -> list[dict]:
"""List all items in the vault."""
output = self._run_bw(["list", "items"])
return json.loads(output)
# -------------------------------------------------------------------
# Collections
# -------------------------------------------------------------------
def _get_collection_id(self, collection_name: str) -> Optional[str]:
"""Look up a collection ID by name. Returns None if not found."""
try:
@@ -170,41 +302,7 @@ class BitwardenHelper:
def create_collection(self, collection_name: str, org_id: str) -> str:
"""Create a collection in an organization."""
item = {"name": collection_name, "organizationId": org_id}
encoded = json.dumps(item)
result = subprocess.run(
["bw", "encode"],
input=encoded,
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"bw encode failed: {result.stderr.strip()}")
encoded_item = result.stdout.strip()
encoded_item = self._encode(item)
output = self._run_bw(["create", "collection", encoded_item])
created = json.loads(output)
return created.get("id", "")
def item_exists(self, name: str) -> bool:
"""Check if a Bitwarden item with this name already exists."""
try:
self._run_bw(["get", "item", name])
return True
except RuntimeError as e:
# Distinguish "not found" (expected) from real errors (network, session expired).
err_msg = str(e).lower()
if "not found" in err_msg or "no item" in err_msg:
return False
# Real error — re-raise so we don't silently create duplicates.
raise
def get_item_password(self, name: str) -> str:
"""Get the password field from a Bitwarden item."""
return self._run_bw(["get", "password", name])
def get_item_uri(self, name: str) -> str:
"""Get the URI from a Bitwarden item."""
output = self._run_bw(["get", "item", name])
item = json.loads(output)
uris = item.get("login", {}).get("uris", [])
return uris[0]["uri"] if uris else ""