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.
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user