fix(auth): use PBKDF2 for master password hash and fix API param casing

This commit is contained in:
2026-08-26 14:16:54 +02:00
parent 3a766bfc2c
commit 6f590a219b
+60 -16
View File
@@ -3,12 +3,32 @@ Bitwarden/Vaultwarden REST API client.
Handles authentication, vault sync, and API communication. Handles authentication, vault sync, and API communication.
""" """
import json import json
import logging
import os import os
import ssl import ssl
from base64 import b64decode, b64encode from base64 import b64decode, b64encode
from typing import Optional, Tuple from typing import Optional, Tuple
import aiohttp 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 from crypto import BitwardenCrypto, PinCrypto
@@ -105,7 +125,9 @@ class BitwardenClient:
method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context() method, url, headers=headers, json=data, params=params, ssl=_get_ssl_context()
) as resp: ) as resp:
text = await resp.text() text = await resp.text()
_debug(f"[REQUEST] {method} {url} -> {resp.status}")
if resp.status >= 400: if resp.status >= 400:
_debug(f"[REQUEST] error body: {text[:500]}")
try: try:
error_data = json.loads(text) error_data = json.loads(text)
message = error_data.get("error_model", {}).get( message = error_data.get("error_model", {}).get(
@@ -122,20 +144,26 @@ class BitwardenClient:
"""Get KDF settings for the user.""" """Get KDF settings for the user."""
url = f"{self._get_api_url()}/accounts/prelogin" url = f"{self._get_api_url()}/accounts/prelogin"
data = {"email": email} data = {"email": email}
_debug(f"[PRELOGIN] POST {url}")
_debug(f"[PRELOGIN] request data: {json.dumps(data)}")
result = await self._request("POST", url, data=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_type = result.get("kdf") or result.get("Kdf", 0)
self.kdf_iterations = result.get("kdfIterations", 600000) self.kdf_iterations = result.get("kdfIterations") or result.get("KdfIterations", 600000)
self.kdf_memory = result.get("kdfMemory") self.kdf_memory = result.get("kdfMemory") or result.get("KdfMemory")
self.kdf_parallelism = result.get("kdfParallelism") self.kdf_parallelism = result.get("kdfParallelism") or result.get("KdfParallelism")
self.email = email 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 return result
async def login_password( async def login_password(
self, email: str, password: str, two_factor_token: Optional[str] = None self, email: str, password: str, two_factor_token: Optional[str] = None
) -> dict: ) -> dict:
"""Login with email and master password.""" """Login with email and master password."""
_debug(f"[LOGIN] Starting password login for: {email}")
await self.prelogin(email) await self.prelogin(email)
# Derive master key # Derive master key
@@ -154,12 +182,19 @@ class BitwardenClient:
else: else:
raise ValueError(f"Unsupported KDF type: {self.kdf_type}") raise ValueError(f"Unsupported KDF type: {self.kdf_type}")
# Hash master password for auth _debug(f"[LOGIN] master_key (first 8 bytes b64): {b64encode(master_key[:8]).decode()}")
master_password_hash = b64encode(
self.crypto.hmac_sha256( # Hash master password for auth (Bitwarden uses PBKDF2 with 1 iteration, NOT HMAC)
master_key, password.encode("utf-8") _pwd_hash_kdf = PBKDF2HMAC(
) algorithm=hashes.SHA256(),
).decode("utf-8") 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 # Build auth request
url = f"{self.identity_url}/connect/token" url = f"{self.identity_url}/connect/token"
@@ -169,18 +204,22 @@ class BitwardenClient:
"password": master_password_hash, "password": master_password_hash,
"scope": "api offline_access", "scope": "api offline_access",
"client_id": "web", "client_id": "web",
"deviceType": DEVICE_TYPE, "device_type": str(DEVICE_TYPE),
"deviceIdentifier": "decky-vaultwarden", "device_identifier": "decky-vaultwarden",
"deviceName": "decky-vaultwarden", "device_name": "decky-vaultwarden",
} }
headers = {"Content-Type": "application/x-www-form-urlencoded"} headers = {"Content-Type": "application/x-www-form-urlencoded"}
# Handle 2FA if needed # Handle 2FA if needed
if two_factor_token: if two_factor_token:
data["twoFactorToken"] = two_factor_token data["two_factor_token"] = two_factor_token
data["twoFactorProvider"] = "0" # Authenticator data["two_factor_provider"] = "0" # Authenticator
data["twoFactorRemember"] = "1" 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 # Use form data instead of JSON
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@@ -188,9 +227,12 @@ class BitwardenClient:
url, data=data, headers=headers, ssl=_get_ssl_context() url, data=data, headers=headers, ssl=_get_ssl_context()
) as resp: ) as resp:
text = await resp.text() text = await resp.text()
_debug(f"[LOGIN] response status: {resp.status}")
_debug(f"[LOGIN] response body: {text[:500]}")
if resp.status >= 400: if resp.status >= 400:
try: try:
error_data = json.loads(text) error_data = json.loads(text)
_debug(f"[LOGIN] error response: {json.dumps(error_data, indent=2)}")
# Check if 2FA is required # Check if 2FA is required
if error_data.get("error") == "invalid_grant" and "twoFactor" in text: if error_data.get("error") == "invalid_grant" and "twoFactor" in text:
return {"two_factor_required": True} return {"two_factor_required": True}
@@ -198,10 +240,12 @@ class BitwardenClient:
"message", text "message", text
) )
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
_debug(f"[LOGIN] raw error text: {text}")
message = text message = text
raise Exception(f"Login failed: {message}") raise Exception(f"Login failed: {message}")
result = json.loads(text) result = json.loads(text)
_debug("[LOGIN] SUCCESS - token received")
self.access_token = result.get("access_token") self.access_token = result.get("access_token")
self.refresh_token = result.get("refresh_token") self.refresh_token = result.get("refresh_token")
self.user_id = result.get("Profile", {}).get("id") or result.get("sub") self.user_id = result.get("Profile", {}).get("id") or result.get("sub")