411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""
|
|
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
|
|
|
|
|
|
def _get_ssl_context():
|
|
ssl_ctx = ssl.create_default_context()
|
|
for ca_path in ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/certs/ca-bundle.crt"]:
|
|
if os.path.exists(ca_path):
|
|
ssl_ctx.load_verify_locations(ca_path)
|
|
break
|
|
return ssl_ctx
|
|
|
|
|
|
# Default URLs
|
|
BITWARDEN_API_BASE = "https://api.bitwarden.com"
|
|
BITWARDEN_IDENTITY_BASE = "https://identity.bitwarden.com"
|
|
|
|
# OAuth2 device type (2 = CLI)
|
|
DEVICE_TYPE = 15 # Use CLI device type for compatibility
|
|
|
|
|
|
class BitwardenClient:
|
|
"""REST API client for Bitwarden/Vaultwarden."""
|
|
|
|
def __init__(self, server_url: Optional[str] = None):
|
|
self.server_url = server_url or BITWARDEN_API_BASE
|
|
self.identity_url = self._get_identity_url()
|
|
self.crypto = BitwardenCrypto()
|
|
self.pin_crypto = PinCrypto()
|
|
self.access_token: Optional[str] = None
|
|
self.refresh_token: Optional[str] = None
|
|
self.enc_key: Optional[bytes] = None
|
|
self.mac_key: Optional[bytes] = None
|
|
self.user_id: Optional[str] = None
|
|
self.email: Optional[str] = None
|
|
self.kdf_type: int = 0
|
|
self.kdf_iterations: int = 600000
|
|
self.kdf_memory: Optional[int] = None
|
|
self.kdf_parallelism: Optional[int] = None
|
|
self._vault_data: Optional[dict] = None
|
|
|
|
def _get_identity_url(self) -> str:
|
|
"""Get the identity endpoint URL."""
|
|
if self.server_url:
|
|
base = self.server_url.rstrip("/")
|
|
# Handle Bitwarden cloud URLs
|
|
if base == "https://api.bitwarden.com":
|
|
return BITWARDEN_IDENTITY_BASE
|
|
if base == "https://api.bitwarden.eu":
|
|
return "https://identity.bitwarden.eu"
|
|
# Handle self-hosted URLs (Vaultwarden)
|
|
# Vaultwarden uses /identity directly
|
|
if "/identity" in base:
|
|
return base
|
|
if "/api" in base:
|
|
return base.replace("/api", "/identity")
|
|
return f"{base}/identity"
|
|
return BITWARDEN_IDENTITY_BASE
|
|
|
|
def _get_api_url(self) -> str:
|
|
"""Get the API endpoint URL."""
|
|
if self.server_url:
|
|
base = self.server_url.rstrip("/")
|
|
# Handle Bitwarden cloud URLs
|
|
if base == "https://identity.bitwarden.com":
|
|
return BITWARDEN_API_BASE
|
|
if base == "https://identity.bitwarden.eu":
|
|
return "https://api.bitwarden.eu"
|
|
# Handle self-hosted URLs (Vaultwarden)
|
|
if "/api" in base:
|
|
return base
|
|
if "/identity" in base:
|
|
return base.replace("/identity", "/api")
|
|
return f"{base}/api"
|
|
return BITWARDEN_API_BASE
|
|
|
|
async def _request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: dict = None,
|
|
data: dict = None,
|
|
params: dict = None,
|
|
) -> dict:
|
|
"""Make an HTTP request."""
|
|
if headers is None:
|
|
headers = {}
|
|
headers.setdefault("Content-Type", "application/json")
|
|
headers.setdefault("Accept", "application/json")
|
|
headers.setdefault("Device-Type", str(DEVICE_TYPE))
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.request(
|
|
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(
|
|
"message", text
|
|
)
|
|
except (json.JSONDecodeError, KeyError):
|
|
message = text
|
|
raise Exception(
|
|
f"API error {resp.status}: {message}"
|
|
)
|
|
return json.loads(text) if text else {}
|
|
|
|
async def prelogin(self, email: str) -> dict:
|
|
"""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") 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
|
|
if self.kdf_type == 0: # PBKDF2
|
|
master_key = self.crypto.derive_master_key_pbkdf2(
|
|
password, email, self.kdf_iterations
|
|
)
|
|
elif self.kdf_type == 1: # Argon2id
|
|
master_key = self.crypto.derive_master_key_argon2(
|
|
password,
|
|
email,
|
|
self.kdf_iterations,
|
|
self.kdf_memory,
|
|
self.kdf_parallelism,
|
|
)
|
|
else:
|
|
raise ValueError(f"Unsupported KDF type: {self.kdf_type}")
|
|
|
|
_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"
|
|
data = {
|
|
"grant_type": "password",
|
|
"username": email,
|
|
"password": master_password_hash,
|
|
"scope": "api offline_access",
|
|
"client_id": "web",
|
|
"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["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:
|
|
async with session.post(
|
|
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}
|
|
message = error_data.get("error_model", {}).get(
|
|
"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")
|
|
|
|
# Get encrypted user key
|
|
enc_user_key = result.get("Key")
|
|
_debug(f"[LOGIN] enc_user_key present: {enc_user_key is not None}")
|
|
if enc_user_key:
|
|
_debug(f"[LOGIN] enc_user_key (first 40): {enc_user_key[:40]}...")
|
|
_debug(f"[LOGIN] attempting to decrypt user key with master_key...")
|
|
try:
|
|
self.enc_key, self.mac_key = self.crypto.decrypt_user_key(
|
|
enc_user_key, master_key
|
|
)
|
|
_debug(f"[LOGIN] user key decrypted successfully!")
|
|
_debug(f"[LOGIN] enc_key (first 8 b64): {b64encode(self.enc_key[:8]).decode()}")
|
|
_debug(f"[LOGIN] mac_key (first 8 b64): {b64encode(self.mac_key[:8]).decode()}")
|
|
except Exception as e:
|
|
_debug(f"[LOGIN] user key decryption FAILED: {e}")
|
|
raise
|
|
return {
|
|
"success": True,
|
|
"master_key": master_key,
|
|
"enc_key": self.enc_key,
|
|
"mac_key": self.mac_key,
|
|
}
|
|
|
|
return {"success": True, "master_key": master_key}
|
|
|
|
async def login_api_key(
|
|
self, client_id: str, client_secret: str, email: str
|
|
) -> dict:
|
|
"""Login with API key (OAuth2 client_credentials)."""
|
|
url = f"{self.identity_url}/connect/token"
|
|
data = {
|
|
"grant_type": "client_credentials",
|
|
"scope": "api",
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
}
|
|
|
|
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
async with session.post(
|
|
url, data=data, headers=headers, ssl=_get_ssl_context()
|
|
) as resp:
|
|
text = await resp.text()
|
|
if resp.status >= 400:
|
|
try:
|
|
error_data = json.loads(text)
|
|
message = error_data.get("error_model", {}).get(
|
|
"message", text
|
|
)
|
|
except (json.JSONDecodeError, KeyError):
|
|
message = text
|
|
raise Exception(f"API key login failed: {message}")
|
|
result = json.loads(text)
|
|
|
|
self.access_token = result.get("access_token")
|
|
self.user_id = result.get("sub")
|
|
|
|
return {
|
|
"success": True,
|
|
"needs_master_password": True,
|
|
"message": "API key authenticated. Master password required for decryption.",
|
|
}
|
|
|
|
async def unlock_with_master_password(
|
|
self, master_password: str
|
|
) -> dict:
|
|
"""Derive keys from master password for decryption."""
|
|
if not self.email:
|
|
raise ValueError("Must login first")
|
|
|
|
await self.prelogin(self.email)
|
|
|
|
if self.kdf_type == 0:
|
|
master_key = self.crypto.derive_master_key_pbkdf2(
|
|
master_password, self.email, self.kdf_iterations
|
|
)
|
|
elif self.kdf_type == 1:
|
|
master_key = self.crypto.derive_master_key_argon2(
|
|
master_password,
|
|
self.email,
|
|
self.kdf_iterations,
|
|
self.kdf_memory,
|
|
self.kdf_parallelism,
|
|
)
|
|
else:
|
|
raise ValueError(f"Unsupported KDF type: {self.kdf_type}")
|
|
|
|
return {"master_key": master_key}
|
|
|
|
async def sync_vault(self) -> dict:
|
|
"""Sync and decrypt the full vault."""
|
|
if not self.access_token:
|
|
raise ValueError("Not authenticated")
|
|
|
|
url = f"{self._get_api_url()}/sync"
|
|
headers = {
|
|
"Authorization": f"Bearer {self.access_token}",
|
|
}
|
|
params = {"excludeDomains": "true"}
|
|
|
|
result = await self._request("GET", url, headers=headers, params=params)
|
|
self._vault_data = result
|
|
return result
|
|
|
|
def decrypt_vault(
|
|
self, sync_data: dict, enc_key: bytes, mac_key: bytes
|
|
) -> dict:
|
|
"""Decrypt all items in the vault."""
|
|
ciphers = sync_data.get("ciphers", [])
|
|
folders = sync_data.get("folders", [])
|
|
collections = sync_data.get("collections", [])
|
|
|
|
# Decrypt folders
|
|
decrypted_folders = []
|
|
for folder in folders:
|
|
name = ""
|
|
if folder.get("name"):
|
|
try:
|
|
name = self.crypto.decrypt_cipher_string(
|
|
folder["name"], enc_key, mac_key
|
|
).decode("utf-8")
|
|
except Exception:
|
|
name = "[encrypted]"
|
|
decrypted_folders.append({
|
|
"id": folder.get("id"),
|
|
"name": name,
|
|
"revisionDate": folder.get("revisionDate"),
|
|
})
|
|
|
|
# Decrypt ciphers
|
|
decrypted_ciphers = []
|
|
for cipher in ciphers:
|
|
if cipher.get("type") in [1, 2, 3, 4]: # Login, Note, Card, Identity
|
|
decrypted = self.crypto.decrypt_cipher(
|
|
cipher, enc_key, mac_key
|
|
)
|
|
if decrypted:
|
|
decrypted_ciphers.append(decrypted)
|
|
|
|
# Build folder map
|
|
folder_map = {f["id"]: f["name"] for f in decrypted_folders}
|
|
|
|
return {
|
|
"ciphers": decrypted_ciphers,
|
|
"folders": decrypted_folders,
|
|
"folder_map": folder_map,
|
|
"collections": collections,
|
|
}
|
|
|
|
def get_kdf_info(self) -> dict:
|
|
"""Get current KDF configuration."""
|
|
return {
|
|
"kdf_type": self.kdf_type,
|
|
"kdf_iterations": self.kdf_iterations,
|
|
"kdf_memory": self.kdf_memory,
|
|
"kdf_parallelism": self.kdf_parallelism,
|
|
}
|