""" Bitwarden/Vaultwarden REST API client. Handles authentication, vault sync, and API communication. """ import json import logging import os import ssl from base64 import b64decode, b64encode from typing import Optional, Tuple import aiohttp from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes log = logging.getLogger("decky-vaultwarden") _DEBUG_LOG = "/tmp/decky-vaultwarden-debug.log" def _debug(msg: str): """Write debug info to file for easy inspection.""" import datetime ts = datetime.datetime.now().isoformat() line = f"[{ts}] {msg}\n" try: with open(_DEBUG_LOG, "a") as f: f.write(line) f.flush() except Exception: pass from crypto import BitwardenCrypto, PinCrypto def _get_ssl_context(): ssl_ctx = ssl.create_default_context() for ca_path in ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs/ca-bundle.crt"]: if os.path.exists(ca_path): ssl_ctx.load_verify_locations(ca_path) break return ssl_ctx # Default URLs BITWARDEN_API_BASE = "https://api.bitwarden.com" BITWARDEN_IDENTITY_BASE = "https://identity.bitwarden.com" # OAuth2 device type (2 = CLI) DEVICE_TYPE = 15 # Use CLI device type for compatibility class BitwardenClient: """REST API client for Bitwarden/Vaultwarden.""" def __init__(self, server_url: Optional[str] = None): self.server_url = server_url or BITWARDEN_API_BASE self.identity_url = self._get_identity_url() self.crypto = BitwardenCrypto() self.pin_crypto = PinCrypto() self.access_token: Optional[str] = None self.refresh_token: Optional[str] = None self.enc_key: Optional[bytes] = None self.mac_key: Optional[bytes] = None self.user_id: Optional[str] = None self.email: Optional[str] = None self.kdf_type: int = 0 self.kdf_iterations: int = 600000 self.kdf_memory: Optional[int] = None self.kdf_parallelism: Optional[int] = None self._vault_data: Optional[dict] = None def _get_identity_url(self) -> str: """Get the identity endpoint URL.""" if self.server_url: base = self.server_url.rstrip("/") # Handle Bitwarden cloud URLs if base == "https://api.bitwarden.com": return BITWARDEN_IDENTITY_BASE if base == "https://api.bitwarden.eu": return "https://identity.bitwarden.eu" # Handle self-hosted URLs (Vaultwarden) # Vaultwarden uses /identity directly if "/identity" in base: return base if "/api" in base: return base.replace("/api", "/identity") return f"{base}/identity" return BITWARDEN_IDENTITY_BASE def _get_api_url(self) -> str: """Get the API endpoint URL.""" if self.server_url: base = self.server_url.rstrip("/") # Handle Bitwarden cloud URLs if base == "https://identity.bitwarden.com": return BITWARDEN_API_BASE if base == "https://identity.bitwarden.eu": return "https://api.bitwarden.eu" # Handle self-hosted URLs (Vaultwarden) if "/api" in base: return base if "/identity" in base: return base.replace("/identity", "/api") return f"{base}/api" return BITWARDEN_API_BASE async def _request( self, method: str, url: str, headers: dict = None, data: dict = None, params: dict = None, ) -> dict: """Make an HTTP request.""" if headers is None: headers = {} headers.setdefault("Content-Type", "application/json") headers.setdefault("Accept", "application/json") headers.setdefault("Device-Type", str(DEVICE_TYPE)) async with aiohttp.ClientSession() as session: async with session.request( method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context() ) as resp: text = await resp.text() _debug(f"[REQUEST] {method} {url} -> {resp.status}") if resp.status >= 400: _debug(f"[REQUEST] error body: {text[:500]}") try: error_data = json.loads(text) message = error_data.get("error_model", {}).get( "message", text ) except (json.JSONDecodeError, KeyError): message = text raise Exception( f"API error {resp.status}: {message}" ) return json.loads(text) if text else {} async def prelogin(self, email: str) -> dict: """Get KDF settings for the user.""" url = f"{self._get_api_url()}/accounts/prelogin" data = {"email": email} _debug(f"[PRELOGIN] POST {url}") _debug(f"[PRELOGIN] request data: {json.dumps(data)}") result = await self._request("POST", url, data=data) _debug(f"[PRELOGIN] response: {json.dumps(result)}") self.kdf_type = result.get("kdf") or result.get("Kdf", 0) self.kdf_iterations = result.get("kdfIterations") or result.get("KdfIterations", 600000) self.kdf_memory = result.get("kdfMemory") or result.get("KdfMemory") self.kdf_parallelism = result.get("kdfParallelism") or result.get("KdfParallelism") self.email = email _debug(f"[PRELOGIN] parsed kdf_type={self.kdf_type}, kdf_iterations={self.kdf_iterations}, kdf_memory={self.kdf_memory}, kdf_parallelism={self.kdf_parallelism}") return result async def login_password( self, email: str, password: str, two_factor_token: Optional[str] = None ) -> dict: """Login with email and master password.""" _debug(f"[LOGIN] Starting password login for: {email}") await self.prelogin(email) # Derive master key if self.kdf_type == 0: # PBKDF2 master_key = self.crypto.derive_master_key_pbkdf2( password, email, self.kdf_iterations ) elif self.kdf_type == 1: # Argon2id master_key = self.crypto.derive_master_key_argon2( password, email, self.kdf_iterations, self.kdf_memory, self.kdf_parallelism, ) else: raise ValueError(f"Unsupported KDF type: {self.kdf_type}") _debug(f"[LOGIN] master_key (first 8 bytes b64): {b64encode(master_key[:8]).decode()}") # Hash master password for auth (Bitwarden uses PBKDF2 with 1 iteration, NOT HMAC) _pwd_hash_kdf = PBKDF2HMAC( algorithm=hashes.SHA256(), length=32, salt=password.encode("utf-8"), iterations=1, backend=self.crypto.backend, ) master_password_hash = b64encode(_pwd_hash_kdf.derive(master_key)).decode("utf-8") _debug(f"[LOGIN] master_password_hash: {master_password_hash}") # Build auth request url = f"{self.identity_url}/connect/token" data = { "grant_type": "password", "username": email, "password": master_password_hash, "scope": "api offline_access", "client_id": "web", "device_type": str(DEVICE_TYPE), "device_identifier": "decky-vaultwarden", "device_name": "decky-vaultwarden", } headers = {"Content-Type": "application/x-www-form-urlencoded"} # Handle 2FA if needed if two_factor_token: data["two_factor_token"] = two_factor_token data["two_factor_provider"] = "0" # Authenticator data["two_factor_remember"] = "1" _debug(f"[LOGIN] POST {url}") _debug(f"[LOGIN] form data (excl password): {json.dumps({k: v for k, v in data.items() if k != 'password'}, indent=2)}") _debug(f"[LOGIN] password field (master_password_hash): {data['password']}") # Use form data instead of JSON async with aiohttp.ClientSession() as session: async with session.post( url, data=data, headers=headers, ssl=_get_ssl_context() ) as resp: text = await resp.text() _debug(f"[LOGIN] response status: {resp.status}") _debug(f"[LOGIN] response body: {text[:500]}") if resp.status >= 400: try: error_data = json.loads(text) _debug(f"[LOGIN] error response: {json.dumps(error_data, indent=2)}") # Check if 2FA is required if error_data.get("error") == "invalid_grant" and "twoFactor" in text: return {"two_factor_required": True} message = error_data.get("error_model", {}).get( "message", text ) except (json.JSONDecodeError, KeyError): _debug(f"[LOGIN] raw error text: {text}") message = text raise Exception(f"Login failed: {message}") result = json.loads(text) _debug("[LOGIN] SUCCESS - token received") self.access_token = result.get("access_token") self.refresh_token = result.get("refresh_token") self.user_id = result.get("Profile", {}).get("id") or result.get("sub") # Get encrypted user key enc_user_key = result.get("Key") _debug(f"[LOGIN] enc_user_key present: {enc_user_key is not None}") if enc_user_key: _debug(f"[LOGIN] enc_user_key (first 40): {enc_user_key[:40]}...") _debug(f"[LOGIN] attempting to decrypt user key with master_key...") try: self.enc_key, self.mac_key = self.crypto.decrypt_user_key( enc_user_key, master_key ) _debug(f"[LOGIN] user key decrypted successfully!") _debug(f"[LOGIN] enc_key (first 8 b64): {b64encode(self.enc_key[:8]).decode()}") _debug(f"[LOGIN] mac_key (first 8 b64): {b64encode(self.mac_key[:8]).decode()}") except Exception as e: _debug(f"[LOGIN] user key decryption FAILED: {e}") raise return { "success": True, "master_key": master_key, "enc_key": self.enc_key, "mac_key": self.mac_key, } return {"success": True, "master_key": master_key} async def login_api_key( self, client_id: str, client_secret: str, email: str ) -> dict: """Login with API key (OAuth2 client_credentials).""" url = f"{self.identity_url}/connect/token" data = { "grant_type": "client_credentials", "scope": "api", "client_id": client_id, "client_secret": client_secret, } headers = {"Content-Type": "application/x-www-form-urlencoded"} async with aiohttp.ClientSession() as session: async with session.post( url, data=data, headers=headers, ssl=_get_ssl_context() ) as resp: text = await resp.text() if resp.status >= 400: try: error_data = json.loads(text) message = error_data.get("error_model", {}).get( "message", text ) except (json.JSONDecodeError, KeyError): message = text raise Exception(f"API key login failed: {message}") result = json.loads(text) self.access_token = result.get("access_token") self.user_id = result.get("sub") return { "success": True, "needs_master_password": True, "message": "API key authenticated. Master password required for decryption.", } async def unlock_with_master_password( self, master_password: str ) -> dict: """Derive keys from master password for decryption.""" if not self.email: raise ValueError("Must login first") await self.prelogin(self.email) if self.kdf_type == 0: master_key = self.crypto.derive_master_key_pbkdf2( master_password, self.email, self.kdf_iterations ) elif self.kdf_type == 1: master_key = self.crypto.derive_master_key_argon2( master_password, self.email, self.kdf_iterations, self.kdf_memory, self.kdf_parallelism, ) else: raise ValueError(f"Unsupported KDF type: {self.kdf_type}") return {"master_key": master_key} async def sync_vault(self) -> dict: """Sync and decrypt the full vault.""" if not self.access_token: raise ValueError("Not authenticated") url = f"{self._get_api_url()}/sync" headers = { "Authorization": f"Bearer {self.access_token}", } params = {"excludeDomains": "true"} result = await self._request("GET", url, headers=headers, params=params) self._vault_data = result return result def decrypt_vault( self, sync_data: dict, enc_key: bytes, mac_key: bytes ) -> dict: """Decrypt all items in the vault.""" ciphers = sync_data.get("ciphers", []) folders = sync_data.get("folders", []) collections = sync_data.get("collections", []) # Decrypt folders decrypted_folders = [] for folder in folders: name = "" if folder.get("name"): try: name = self.crypto.decrypt_cipher_string( folder["name"], enc_key, mac_key ).decode("utf-8") except Exception: name = "[encrypted]" decrypted_folders.append({ "id": folder.get("id"), "name": name, "revisionDate": folder.get("revisionDate"), }) # Decrypt ciphers decrypted_ciphers = [] for cipher in ciphers: if cipher.get("type") in [1, 2, 3, 4]: # Login, Note, Card, Identity decrypted = self.crypto.decrypt_cipher( cipher, enc_key, mac_key ) if decrypted: decrypted_ciphers.append(decrypted) # Build folder map folder_map = {f["id"]: f["name"] for f in decrypted_folders} return { "ciphers": decrypted_ciphers, "folders": decrypted_folders, "folder_map": folder_map, "collections": collections, } def get_kdf_info(self) -> dict: """Get current KDF configuration.""" return { "kdf_type": self.kdf_type, "kdf_iterations": self.kdf_iterations, "kdf_memory": self.kdf_memory, "kdf_parallelism": self.kdf_parallelism, }