diff --git a/bitwarden_client.py b/bitwarden_client.py index de138a2..4916590 100644 --- a/bitwarden_client.py +++ b/bitwarden_client.py @@ -15,21 +15,6 @@ 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 @@ -125,9 +110,7 @@ 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( @@ -144,10 +127,7 @@ 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") or result.get("Kdf", 0) self.kdf_iterations = result.get("kdfIterations") or result.get("KdfIterations", 600000) @@ -155,7 +135,6 @@ class BitwardenClient: 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 @@ -163,7 +142,6 @@ class BitwardenClient: 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 @@ -182,7 +160,6 @@ class BitwardenClient: 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( @@ -194,7 +171,6 @@ class BitwardenClient: ) 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" @@ -217,9 +193,6 @@ class BitwardenClient: 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: @@ -227,12 +200,9 @@ 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} @@ -240,32 +210,20 @@ 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") # 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 + self.enc_key, self.mac_key = self.crypto.decrypt_user_key( + enc_user_key, master_key + ) return { "success": True, "master_key": master_key, @@ -383,7 +341,8 @@ class BitwardenClient: # Decrypt ciphers decrypted_ciphers = [] for cipher in ciphers: - if cipher.get("type") in [0, 1, 2, 3]: # Login, Note, Card, Identity + ctype = cipher.get("type") + if ctype in [1, 2, 3, 4]: # Login, Note, Card, Identity decrypted = self.crypto.decrypt_cipher( cipher, enc_key, mac_key ) diff --git a/crypto.py b/crypto.py index 2c28994..b51d718 100644 --- a/crypto.py +++ b/crypto.py @@ -10,18 +10,6 @@ import struct from base64 import b64decode, b64encode from typing import Optional, Tuple -_debug_log = "/tmp/decky-vaultwarden-debug.log" - -def _debug(msg: str): - import datetime - ts = datetime.datetime.now().isoformat() - try: - with open(_debug_log, "a") as f: - f.write(f"[{ts}] {msg}\n") - f.flush() - except Exception: - pass - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives import hashes, padding from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC @@ -87,16 +75,9 @@ class BitwardenCrypto: def stretch_master_key(self, master_key: bytes) -> Tuple[bytes, bytes]: """Stretch master key into encryption key + MAC key using HKDF-expand.""" - _debug(f"[CRYPTO] stretch_master_key: master_key (first 8 b64)={b64encode(master_key[:8]).decode()}") - _debug(f"[CRYPTO] stretch_master_key: master_key length={len(master_key)}") - # Bitwarden SDK: HKDF-expand(key, info) = HMAC-SHA256(key, info || 0x01) enc_key = hmac.new(master_key, b"enc\x01", hashlib.sha256).digest() mac_key = hmac.new(master_key, b"mac\x01", hashlib.sha256).digest() - - _debug(f"[CRYPTO] stretch: enc_key (first 8 b64)={b64encode(enc_key[:8]).decode()}") - _debug(f"[CRYPTO] stretch: mac_key (first 8 b64)={b64encode(mac_key[:8]).decode()}") - return enc_key, mac_key def decrypt_aes_cbc_256( @@ -131,11 +112,9 @@ class BitwardenCrypto: Format: TYPE.BASE64(IV) | BASE64(CT) | BASE64(MAC) or: TYPE.BASE64(IV).BASE64(CT).BASE64(MAC) """ - _debug(f"[CRYPTO] decrypt_cipher_string: type={enc_string[:2]}, full={enc_string[:50]}...") # Determine cipher type if enc_string.startswith(CIPHER_TYPE_AES_CBC_256_B64): parts = enc_string[2:].split("|") - _debug(f"[CRYPTO] decrypt_cipher_string: type 2, parts count={len(parts)}") if len(parts) == 2: # Format: TYPE.BASE64(IV)|BASE64(CT) iv_b64, ct_b64 = parts @@ -148,13 +127,7 @@ class BitwardenCrypto: iv = b64decode(iv_b64) ct = b64decode(ct_b64) mac = b64decode(mac_b64) - _debug(f"[CRYPTO] decrypt_cipher_string: verifying MAC...") computed_mac = self.hmac_sha256(mac_key, iv + ct) - _debug(f"[CRYPTO] decrypt_cipher_string: computed_mac (b64)={b64encode(computed_mac).decode()}") - _debug(f"[CRYPTO] decrypt_cipher_string: expected_mac (b64)={mac_b64}") - _debug(f"[CRYPTO] decrypt_cipher_string: mac_keys_equal={computed_mac == mac}") - _debug(f"[CRYPTO] decrypt_cipher_string: enc_key (first 8 b64)={b64encode(enc_key[:8]).decode()}") - _debug(f"[CRYPTO] decrypt_cipher_string: mac_key (first 8 b64)={b64encode(mac_key[:8]).decode()}") if not self.verify_mac(mac_key, iv + ct, mac): raise ValueError("MAC verification failed") return self.decrypt_aes_cbc_256(enc_key, iv, ct) @@ -185,13 +158,10 @@ class BitwardenCrypto: The user key is encrypted with the stretched master key. """ - _debug(f"[CRYPTO] decrypt_user_key: encrypted_user_key (first 30)={encrypted_user_key[:30]}...") stretched_enc, stretched_mac = self.stretch_master_key(master_key) - _debug(f"[CRYPTO] decrypt_user_key: calling decrypt_cipher_string...") user_key = self.decrypt_cipher_string( encrypted_user_key, stretched_enc, stretched_mac ) - _debug(f"[CRYPTO] decrypt_user_key: decrypted user_key length={len(user_key)}") if len(user_key) == 64: # Has separate MAC key @@ -233,7 +203,7 @@ class BitwardenCrypto: cipher_type = cipher_data.get("type") - if cipher_type == 0: # Login + if cipher_type == 1: # Login login = cipher_data.get("login", {}) result["login"] = { "username": self._decrypt_field( @@ -255,11 +225,11 @@ class BitwardenCrypto: for u in login.get("uris", []) ], } - elif cipher_type == 1: # Secure Note + elif cipher_type == 2: # Secure Note result["notes"] = self._decrypt_field( cipher_data.get("notes"), enc_key, mac_key ) - elif cipher_type == 2: # Card + elif cipher_type == 3: # Card card = cipher_data.get("card", {}) result["card"] = { "cardholderName": self._decrypt_field( @@ -278,7 +248,7 @@ class BitwardenCrypto: card.get("expYear"), enc_key, mac_key ), } - elif cipher_type == 3: # Identity + elif cipher_type == 4: # Identity identity = cipher_data.get("identity", {}) result["identity"] = { "firstName": self._decrypt_field( @@ -311,7 +281,7 @@ class BitwardenCrypto: result["fields"] = fields # Decrypt notes (only for non-Note types, Notes already handled above) - if cipher_data.get("notes") and cipher_type != 1: + if cipher_data.get("notes") and cipher_type != 2: result["notes"] = self._decrypt_field( cipher_data.get("notes"), enc_key, mac_key ) diff --git a/main.py b/main.py index bbe663d..5e8ef6d 100644 --- a/main.py +++ b/main.py @@ -242,32 +242,27 @@ class Plugin: # ===== Credential Copy ===== async def copy_password(self, cipher_id: str) -> dict: - """Copy a password to clipboard with 60s auto-clear.""" - from clipboard import copy_to_clipboard + """Get password for clipboard copy.""" password = self._get_cipher_field(cipher_id, "password") if password is None: return {"success": False, "error": "Password not found"} - return await copy_to_clipboard(password, clear_after=60, label=f"password_{cipher_id}") + return {"success": True, "value": password, "clear_after": 60} async def copy_username(self, cipher_id: str) -> dict: - """Copy a username to clipboard with 60s auto-clear.""" - from clipboard import copy_to_clipboard + """Get username for clipboard copy.""" username = self._get_cipher_field(cipher_id, "username") if username is None: return {"success": False, "error": "Username not found"} - return await copy_to_clipboard(username, clear_after=60, label=f"username_{cipher_id}") + return {"success": True, "value": username, "clear_after": 60} async def copy_totp(self, cipher_id: str) -> dict: - """Copy TOTP code to clipboard with 60s auto-clear.""" - from clipboard import copy_to_clipboard + """Get TOTP code for clipboard copy.""" from totp import generate_totp, get_totp_remaining_seconds totp_secret = self._get_cipher_field(cipher_id, "totp") if totp_secret: code = generate_totp(totp_secret) remaining = get_totp_remaining_seconds() - result = await copy_to_clipboard(code, clear_after=min(remaining, 30), label=f"totp_{cipher_id}") - result["remaining_seconds"] = remaining - return result + return {"success": True, "value": code, "clear_after": min(remaining, 30), "remaining_seconds": remaining} return {"success": False, "error": "TOTP not configured"} async def get_totp_code(self, cipher_id: str) -> dict: diff --git a/src/api/backend.ts b/src/api/backend.ts index 7f42852..1f458a5 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -59,6 +59,7 @@ export interface LoginResult { export interface CopyResult { success?: boolean; + value?: string; clear_after?: number; expires_at?: number; remaining_seconds?: number; diff --git a/src/components/ItemCard.tsx b/src/components/ItemCard.tsx index 1535f48..c9d41a2 100644 --- a/src/components/ItemCard.tsx +++ b/src/components/ItemCard.tsx @@ -1,5 +1,11 @@ -import { useState, useEffect } from "react"; -import { ButtonItem } from "@decky/ui"; +import { useState, useEffect, useRef } from "react"; +import { + Focusable, + showContextMenu, + Menu, + MenuItem, + MenuGroup, +} from "@decky/ui"; import { VaultItem } from "../api/backend"; interface ItemCardProps { @@ -35,6 +41,8 @@ export function ItemCard({ }: ItemCardProps) { const [expanded, setExpanded] = useState(false); const [copying, setCopying] = useState(null); + const [focused, setFocused] = useState(false); + const cardRef = useRef(null); const isCopied = (field: string) => copyStatus?.cipherId === item.id && copyStatus?.field === field; @@ -72,23 +80,71 @@ export function ItemCard({ } }; + const showItemMenu = (e?: Event) => { + const hasUsername = !!item.login?.username; + const hasPassword = !!item.login?.password; + const hasTotp = !!item.login?.totp; + + if (!hasUsername && !hasPassword && !hasTotp) return; + + const parent = e ? (e.target as HTMLElement) : (cardRef.current ?? undefined); + + showContextMenu( + + + {hasUsername && ( + handleCopy("username")} + > + Copy Username + + )} + {hasPassword && ( + handleCopy("password")} + > + Copy Password + + )} + {hasTotp && ( + handleCopy("totp")} + > + Copy TOTP + + )} + + , + parent + ); + }; + const folderName = item.folderId ? folderMap[item.folderId] : null; return ( -
setFocused(true)} + onBlur={() => setFocused(false)} + flow-children="y" + onOKActionDescription="Copy Menu" style={{ background: "#1e1e1e", borderRadius: "8px", padding: "12px", marginBottom: "8px", - border: expanded ? "1px solid #1a9fff" : "1px solid #333", + border: focused + ? "1px solid #1a9fff" + : expanded + ? "1px solid #1a9fff" + : "1px solid #333", }} > {/* Header */}
setExpanded(!expanded)} style={{ - cursor: "pointer", display: "flex", alignItems: "center", gap: "8px", @@ -136,9 +192,6 @@ export function ItemCard({ {folderName} )} - - {expanded ? "\u25B2" : "\u25BC"} -
{/* Expanded content */} @@ -212,7 +265,7 @@ export function ItemCard({
{field.name}: - {field.hidden ? "••••••••" : field.value || ""} + {field.hidden ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : field.value || ""}
))} @@ -244,7 +297,7 @@ export function ItemCard({
{item.card.brand &&
Brand: {item.card.brand}
} {item.card.number && ( -
Number: •••• •••• •••• {item.card.number.slice(-4)}
+
Number: \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 {item.card.number.slice(-4)}
)} {item.card.expMonth && item.card.expYear && (
@@ -256,7 +309,7 @@ export function ItemCard({ )}
)} -
+ ); } diff --git a/src/components/VaultBrowser.tsx b/src/components/VaultBrowser.tsx index a658c05..097eadc 100644 --- a/src/components/VaultBrowser.tsx +++ b/src/components/VaultBrowser.tsx @@ -1,11 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { PanelSection, TextField, ButtonItem, - Spinner, + Focusable, } from "@decky/ui"; -import { VaultData, VaultItem, Folder } from "../api/backend"; +import { VaultData, VaultItem } from "../api/backend"; import { ItemCard } from "./ItemCard"; interface VaultBrowserProps { @@ -102,7 +102,10 @@ export function VaultBrowser({ )} {/* Items */} -
+ {displayedItems.length === 0 ? (
)) )} -
+
{/* Actions */} diff --git a/src/hooks/useVault.ts b/src/hooks/useVault.ts index 5967b3d..e90cfd0 100644 --- a/src/hooks/useVault.ts +++ b/src/hooks/useVault.ts @@ -188,16 +188,45 @@ export function useVault() { } }, []); + const copyToClipboard = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fallback: create temp input, select, execCommand + try { + const tempInput = document.createElement("input"); + tempInput.value = text; + tempInput.style.position = "absolute"; + tempInput.style.left = "-9999px"; + document.body.appendChild(tempInput); + tempInput.focus(); + tempInput.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(tempInput); + return ok; + } catch { + return false; + } + } + }; + const copyPassword = useCallback(async (cipherId: string) => { try { const result = await vaultApi.copyPassword(cipherId); - if (result.success) { + if (result.success && result.value) { + const copied = await copyToClipboard(result.value); + if (!copied) { + setError("Failed to copy to clipboard"); + return false; + } + const clearAfter = result.clear_after || 60; setCopyStatus({ field: "password", cipherId, - expiresAt: result.expires_at || Date.now() / 1000 + 60, + expiresAt: Date.now() / 1000 + clearAfter, }); - setTimeout(() => setCopyStatus(null), 60000); + setTimeout(() => setCopyStatus(null), clearAfter * 1000); return true; } setError(result.error || "Failed to copy"); @@ -211,13 +240,19 @@ export function useVault() { const copyUsername = useCallback(async (cipherId: string) => { try { const result = await vaultApi.copyUsername(cipherId); - if (result.success) { + if (result.success && result.value) { + const copied = await copyToClipboard(result.value); + if (!copied) { + setError("Failed to copy to clipboard"); + return false; + } + const clearAfter = result.clear_after || 60; setCopyStatus({ field: "username", cipherId, - expiresAt: result.expires_at || Date.now() / 1000 + 60, + expiresAt: Date.now() / 1000 + clearAfter, }); - setTimeout(() => setCopyStatus(null), 60000); + setTimeout(() => setCopyStatus(null), clearAfter * 1000); return true; } setError(result.error || "Failed to copy"); @@ -231,13 +266,19 @@ export function useVault() { const copyTotp = useCallback(async (cipherId: string) => { try { const result = await vaultApi.copyTotp(cipherId); - if (result.success) { + if (result.success && result.value) { + const copied = await copyToClipboard(result.value); + if (!copied) { + setError("Failed to copy to clipboard"); + return false; + } + const clearAfter = result.clear_after || 30; setCopyStatus({ field: "totp", cipherId, - expiresAt: result.expires_at || Date.now() / 1000 + 30, + expiresAt: Date.now() / 1000 + clearAfter, }); - setTimeout(() => setCopyStatus(null), 30000); + setTimeout(() => setCopyStatus(null), clearAfter * 1000); return true; } setError(result.error || "Failed to copy TOTP");