From 6f590a219b91b596187cc266c6db53ce69b8953b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Wed, 26 Aug 2026 14:16:54 +0200 Subject: [PATCH] fix(auth): use PBKDF2 for master password hash and fix API param casing --- bitwarden_client.py | 76 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/bitwarden_client.py b/bitwarden_client.py index 93a7d26..dc19461 100644 --- a/bitwarden_client.py +++ b/bitwarden_client.py @@ -3,12 +3,32 @@ 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 @@ -105,7 +125,9 @@ class BitwardenClient: 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( @@ -122,20 +144,26 @@ class BitwardenClient: """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", 0) - self.kdf_iterations = result.get("kdfIterations", 600000) - self.kdf_memory = result.get("kdfMemory") - self.kdf_parallelism = result.get("kdfParallelism") + 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 @@ -154,12 +182,19 @@ class BitwardenClient: else: raise ValueError(f"Unsupported KDF type: {self.kdf_type}") - # Hash master password for auth - master_password_hash = b64encode( - self.crypto.hmac_sha256( - master_key, password.encode("utf-8") - ) - ).decode("utf-8") + _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" @@ -169,18 +204,22 @@ class BitwardenClient: "password": master_password_hash, "scope": "api offline_access", "client_id": "web", - "deviceType": DEVICE_TYPE, - "deviceIdentifier": "decky-vaultwarden", - "deviceName": "decky-vaultwarden", + "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["twoFactorToken"] = two_factor_token - data["twoFactorProvider"] = "0" # Authenticator - data["twoFactorRemember"] = "1" + 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: @@ -188,9 +227,12 @@ class BitwardenClient: 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} @@ -198,10 +240,12 @@ class BitwardenClient: "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")