fix(auth): use PBKDF2 for master password hash and fix API param casing
This commit is contained in:
+60
-16
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user