refactor: move clipboard to frontend, fix cipher type mapping, add context menu

This commit is contained in:
2026-08-26 15:28:35 +02:00
parent a3e9f9ab0e
commit deba9a419d
7 changed files with 140 additions and 118 deletions
+5 -46
View File
@@ -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
)
+5 -35
View File
@@ -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
)
+6 -11
View File
@@ -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:
+1
View File
@@ -59,6 +59,7 @@ export interface LoginResult {
export interface CopyResult {
success?: boolean;
value?: string;
clear_after?: number;
expires_at?: number;
remaining_seconds?: number;
+65 -12
View File
@@ -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<string | null>(null);
const [focused, setFocused] = useState(false);
const cardRef = useRef<HTMLDivElement>(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(
<Menu label={item.name || "Actions"}>
<MenuGroup label="Copy">
{hasUsername && (
<MenuItem
onSelected={() => handleCopy("username")}
>
Copy Username
</MenuItem>
)}
{hasPassword && (
<MenuItem
onSelected={() => handleCopy("password")}
>
Copy Password
</MenuItem>
)}
{hasTotp && (
<MenuItem
onSelected={() => handleCopy("totp")}
>
Copy TOTP
</MenuItem>
)}
</MenuGroup>
</Menu>,
parent
);
};
const folderName = item.folderId ? folderMap[item.folderId] : null;
return (
<div
<Focusable
ref={cardRef}
onOKButton={showItemMenu}
onActivate={showItemMenu}
onFocus={() => 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 */}
<div
onClick={() => setExpanded(!expanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
@@ -136,9 +192,6 @@ export function ItemCard({
{folderName}
</span>
)}
<span style={{ fontSize: "10px", color: "#666" }}>
{expanded ? "\u25B2" : "\u25BC"}
</span>
</div>
{/* Expanded content */}
@@ -212,7 +265,7 @@ export function ItemCard({
<div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}>
<span style={{ color: "#888" }}>{field.name}: </span>
<span style={{ color: "#ccc" }}>
{field.hidden ? "••••••••" : field.value || ""}
{field.hidden ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : field.value || ""}
</span>
</div>
))}
@@ -244,7 +297,7 @@ export function ItemCard({
<div style={{ marginTop: "12px", fontSize: "12px", color: "#ccc" }}>
{item.card.brand && <div>Brand: {item.card.brand}</div>}
{item.card.number && (
<div>Number: •••• •••• •••• {item.card.number.slice(-4)}</div>
<div>Number: \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 \u2022\u2022\u2022\u2022 {item.card.number.slice(-4)}</div>
)}
{item.card.expMonth && item.card.expYear && (
<div>
@@ -256,7 +309,7 @@ export function ItemCard({
)}
</div>
)}
</div>
</Focusable>
);
}
+8 -5
View File
@@ -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 */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
<Focusable
flow-children="y"
style={{ maxHeight: "400px", overflowY: "auto" }}
>
{displayedItems.length === 0 ? (
<div
style={{
@@ -126,7 +129,7 @@ export function VaultBrowser({
/>
))
)}
</div>
</Focusable>
{/* Actions */}
<ButtonItem layout="below" onClick={onLock}>
+50 -9
View File
@@ -188,16 +188,45 @@ export function useVault() {
}
}, []);
const copyToClipboard = async (text: string): Promise<boolean> => {
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");