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:
+158
-60
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user