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.
"""
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")