chore: init decky vaultwarden plugin scaffold

This commit is contained in:
2026-08-25 18:02:07 +02:00
commit 3d066c5297
27 changed files with 5479 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
out/
*.pyc
__pycache__/
.env
*.egg-info/
.venv/
venv/
+34
View File
@@ -0,0 +1,34 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "shell",
"command": "pnpm run build",
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": ["$tsc"]
},
{
"label": "watch",
"type": "shell",
"command": "pnpm run watch",
"group": "build",
"isBackground": true
},
{
"label": "typecheck",
"type": "shell",
"command": "pnpm run typecheck",
"group": "test"
},
{
"label": "install-deps",
"type": "shell",
"command": "pnpm install",
"group": "none"
}
]
}
+26
View File
@@ -0,0 +1,26 @@
MIT License
Copyright (c) 2026 zoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
This plugin is not affiliated with Bitwarden Inc. or Vaultwarden.
Bitwarden is a registered trademark of Bitwarden Inc.
+84
View File
@@ -0,0 +1,84 @@
# Decky Vaultwarden
A Decky Loader plugin for accessing Bitwarden and Vaultwarden passwords directly from your Steam Deck.
## Features
- **Login with Email + Password** or **API Key** (supports self-hosted Vaultwarden)
- **Browse vault** organized by folders with search
- **Copy passwords, usernames, and TOTP codes** to clipboard (auto-clears after 60 seconds)
- **PIN unlock** - set up a PIN to avoid entering your master password every time
- **2FA support** - handles two-factor authentication during login
- **Offline capable** - once synced, vault data is cached locally
- **Auto-lock** - vault locks when Steam Deck goes to sleep
## Installation
1. Install [Decky Loader](https://github.com/SteamDeckHomebrew/decky-loader) on your Steam Deck
2. Download the latest release ZIP from [Releases](https://github.com/zoe/Decky-vaultwarden/releases)
3. In Decky Loader settings, go to Developer > Install Plugin from ZIP File
4. Select the downloaded ZIP file
## Setup
### First Login
1. Open the Decky quick access menu and find the Vaultwarden plugin
2. Enter your server URL:
- For Bitwarden cloud: `https://api.bitwarden.com`
- For self-hosted: `https://your-vaultwarden-domain.com`
3. Enter your email and master password
4. If 2FA is enabled, enter your authenticator code
### Setting Up PIN (Recommended)
After logging in, click "Set Up PIN" to enable quick unlock:
- Enter a 4+ character PIN
- The PIN protects your encryption key locally
- After setup, you only need the PIN to unlock (not the full master password)
- After 5 failed PIN attempts, you'll need to login with master password again
### API Key Login
For automation or if you prefer API keys:
1. Get your API key from Vaultwarden Settings > Security > Keys > View API Key
2. Toggle "Use API Key" in the plugin
3. Enter your Client ID, Client Secret, and Email
4. You'll still need your master password once to decrypt the vault
## Security
- All decryption happens locally on your device
- Master password is never stored
- PIN is used to locally encrypt your vault key (not transmitted)
- Clipboard is cleared after 60 seconds
- Vault locks on Steam Deck sleep (configurable)
- 5 failed PIN attempts triggers logout
## Building from Source
### Prerequisites
- Node.js v16.14+ and pnpm v9
- Python 3.10+ with pip
- Docker (for backend builds)
### Build
```bash
# Install frontend dependencies
pnpm install
# Build frontend
pnpm run build
# The plugin ZIP will be in the out/ directory
```
## Keyboard Shortcuts
- **Ctrl+Shift+L**: Lock vault
## License
MIT License - see [LICENSE](LICENSE) for details.
View File
+344
View File
@@ -0,0 +1,344 @@
"""
Bitwarden/Vaultwarden REST API client.
Handles authentication, vault sync, and API communication.
"""
import json
from base64 import b64decode, b64encode
from typing import Optional, Tuple
import aiohttp
from .crypto import BitwardenCrypto, PinCrypto
# 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
) 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 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}
result = await self._request("POST", url, data=data)
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.email = email
return result
async def login_password(
self, email: str, password: str, two_factor_token: Optional[str] = None
) -> dict:
"""Login with email and master password."""
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}")
# Hash master password for auth
master_password_hash = b64encode(
self.crypto.hmac_sha256(
master_key, password.encode("utf-8")
)
).decode("utf-8")
# 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": "connector",
"deviceType": DEVICE_TYPE,
"deviceName": "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"
# Use form data instead of JSON
async with aiohttp.ClientSession() as session:
async with session.post(
url, data=data, headers=headers
) as resp:
text = await resp.text()
if resp.status >= 400:
try:
error_data = json.loads(text)
# 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):
message = text
raise Exception(f"Login failed: {message}")
result = json.loads(text)
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")
if enc_user_key:
self.enc_key, self.mac_key = self.crypto.decrypt_user_key(
enc_user_key, master_key
)
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
) 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,
}
+123
View File
@@ -0,0 +1,123 @@
"""
Clipboard management with 60-second auto-clear.
Uses xclip/xdotool for SteamOS (KDE Plasma).
"""
import asyncio
import subprocess
import time
from typing import Optional
# Clipboard state
_clipboard_timers: dict = {}
_clipboard_contents: dict = {}
async def copy_to_clipboard(text: str, clear_after: int = 60, label: str = "default") -> dict:
"""Copy text to clipboard with auto-clear after specified seconds."""
global _clipboard_timers, _clipboard_contents
try:
# Cancel any existing timer for this label
if label in _clipboard_timers:
_clipboard_timers[label].cancel()
# Store the text
_clipboard_contents[label] = text
# Copy to clipboard using xclip
process = await asyncio.create_subprocess_exec(
"xclip", "-selection", "clipboard",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate(input=text.encode("utf-8"))
if process.returncode != 0:
# Fallback to xsel if xclip not available
process = await asyncio.create_subprocess_exec(
"xsel", "--clipboard", "--input",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate(input=text.encode("utf-8"))
if process.returncode != 0:
return {"success": False, "error": "No clipboard tool available (xclip or xsel)"}
# Set up auto-clear timer
async def clear_clipboard():
await asyncio.sleep(clear_after)
await clear_clipboard_label(label)
timer = asyncio.create_task(clear_clipboard())
_clipboard_timers[label] = timer
return {
"success": True,
"clear_after": clear_after,
"expires_at": int(time.time()) + clear_after,
}
except FileNotFoundError:
return {"success": False, "error": "xclip/xsel not found. Install with: pacman -S xclip"}
except Exception as e:
return {"success": False, "error": str(e)}
async def clear_clipboard_label(label: str = "default") -> dict:
"""Clear the clipboard for a specific label."""
global _clipboard_timers, _clipboard_contents
# Cancel timer if exists
if label in _clipboard_timers:
_clipboard_timers[label].cancel()
del _clipboard_timers[label]
# Clear clipboard content
if label in _clipboard_contents:
del _clipboard_contents[label]
# Clear the actual clipboard
try:
process = await asyncio.create_subprocess_exec(
"xclip", "-selection", "clipboard",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.communicate(input=b"")
except FileNotFoundError:
pass
return {"success": True}
async def clear_all_clipboards() -> dict:
"""Clear all clipboard contents and cancel all timers."""
global _clipboard_timers, _clipboard_contents
for label in list(_clipboard_timers.keys()):
_clipboard_timers[label].cancel()
_clipboard_timers.clear()
_clipboard_contents.clear()
await clear_clipboard_label("default")
return {"success": True}
def get_clipboard_status(label: str = "default") -> dict:
"""Get the status of clipboard content for a label."""
global _clipboard_contents, _clipboard_timers
if label not in _clipboard_contents:
return {"has_content": False}
return {
"has_content": True,
"label": label,
"has_timer": label in _clipboard_timers,
}
+380
View File
@@ -0,0 +1,380 @@
"""
Bitwarden/Vaultwarden cryptography implementation.
Handles KDF (PBKDF2, Argon2), AES-CBC-256, HMAC, HKDF, and vault decryption.
"""
import hashlib
import hmac
import os
import struct
from base64 import b64decode, b64encode
from typing import Optional, Tuple
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes, padding
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
# Bitwarden-specific constants
ENCRYPTION_KEY_LENGTH = 32 # 256 bits
MAC_KEY_LENGTH = 32
HKDF_INFO_EMAIL = b"enc"
HKDF_INFO_MASTER_PASSWORD = b"enc"
# Cipher type markers (ST arrays in Bitwarden)
CIPHER_TYPE_AES_CBC_256_B64 = "2."
CIPHER_TYPE_AES_CBC_256_HMAC_B64 = "3."
CIPHER_TYPE_RSA_2048_OAEP_SHA256 = "4."
class BitwardenCrypto:
"""Core cryptographic operations for Bitwarden vault decryption."""
def __init__(self):
self.backend = default_backend()
def derive_master_key_pbkdf2(
self, password: str, email: str, iterations: int
) -> bytes:
"""Derive master key using PBKDF2-SHA256."""
salt = email.lower().strip().encode("utf-8")
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=ENCRYPTION_KEY_LENGTH,
salt=salt,
iterations=iterations,
backend=self.backend,
)
return kdf.derive(password.encode("utf-8"))
def derive_master_key_argon2(
self,
password: str,
email: str,
iterations: int,
memory: int,
parallelism: int,
) -> bytes:
"""Derive master key using Argon2id."""
try:
import argon2
salt = email.lower().strip().encode("utf-8")
return argon2.low_level.hash_secret_raw(
secret=password.encode("utf-8"),
salt=salt,
time_cost=iterations,
memory_cost=memory * 1024, # Convert MB to KB
parallelism=parallelism,
hash_len=ENCRYPTION_KEY_LENGTH,
type=argon2.low_level.Type.ID,
)
except ImportError:
raise RuntimeError(
"Argon2 support requires argon2-cffi: pip install argon2-cffi"
)
def stretch_master_key(self, master_key: bytes) -> Tuple[bytes, bytes]:
"""Stretch master key into encryption key + MAC key using HKDF."""
# Bitwarden uses HKDF with empty salt and specific info strings
prk = hmac.new(b"", master_key, hashlib.sha256).digest()
# Encryption key
enc_key = hmac.new(
prk, b"\x01" + HKDF_INFO_EMAIL + b"\x00", hashlib.sha256
).digest()
# MAC key
mac_key = hmac.new(
prk, b"\x02" + HKDF_INFO_EMAIL + b"\x00", hashlib.sha256
).digest()
return enc_key, mac_key
def decrypt_aes_cbc_256(
self, key: bytes, iv: bytes, ciphertext: bytes
) -> bytes:
"""Decrypt data using AES-CBC-256."""
cipher = Cipher(
algorithms.AES(key), modes.CBC(iv), backend=self.backend
)
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
# Remove PKCS7 padding
unpadder = padding.PKCS7(128).unpadder()
return unpadder.update(padded) + unpadder.finalize()
def hmac_sha256(self, key: bytes, data: bytes) -> bytes:
"""Compute HMAC-SHA256."""
return hmac.new(key, data, hashlib.sha256).digest()
def verify_mac(
self, mac_key: bytes, data: bytes, expected_mac: bytes
) -> bool:
"""Verify HMAC-SHA256."""
computed = self.hmac_sha256(mac_key, data)
return hmac.compare_digest(computed, expected_mac)
def decrypt_cipher_string(self, enc_string: str, enc_key: bytes, mac_key: bytes) -> bytes:
"""
Decrypt a Bitwarden CipherString.
Format: TYPE.BASE64(IV) | BASE64(CT) | BASE64(MAC)
or: TYPE.BASE64(IV).BASE64(CT).BASE64(MAC)
"""
# Determine cipher type
if enc_string.startswith(CIPHER_TYPE_AES_CBC_256_B64):
parts = enc_string[2:].split("|")
if len(parts) == 2:
# Format: TYPE.BASE64(IV)|BASE64(CT)
iv_b64, ct_b64 = parts
iv = b64decode(iv_b64)
ct = b64decode(ct_b64)
return self.decrypt_aes_cbc_256(enc_key, iv, ct)
elif len(parts) == 3:
# Format: TYPE.BASE64(IV)|BASE64(CT)|BASE64(MAC)
iv_b64, ct_b64, mac_b64 = parts
iv = b64decode(iv_b64)
ct = b64decode(ct_b64)
mac = b64decode(mac_b64)
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)
elif enc_string.startswith(CIPHER_TYPE_AES_CBC_256_HMAC_B64):
parts = enc_string[2:].split("|")
if len(parts) == 3:
iv_b64, ct_b64, mac_b64 = parts
iv = b64decode(iv_b64)
ct = b64decode(ct_b64)
mac = b64decode(mac_b64)
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)
elif len(parts) == 2:
iv_b64, ct_b64 = parts
iv = b64decode(iv_b64)
ct = b64decode(ct_b64)
return self.decrypt_aes_cbc_256(enc_key, iv, ct)
raise ValueError(f"Unsupported cipher type in: {enc_string[:10]}...")
def decrypt_user_key(
self, encrypted_user_key: str, master_key: bytes
) -> Tuple[bytes, bytes]:
"""
Decrypt the encrypted user key (Key from API response).
Returns (encryption_key, mac_key).
The user key is encrypted with the stretched master key.
"""
stretched_enc, stretched_mac = self.stretch_master_key(master_key)
user_key = self.decrypt_cipher_string(
encrypted_user_key, stretched_enc, stretched_mac
)
if len(user_key) == 64:
# Has separate MAC key
return user_key[:32], user_key[32:]
elif len(user_key) == 32:
# No separate MAC key, derive from the key itself
return user_key, user_key
else:
raise ValueError(f"Unexpected user key length: {len(user_key)}")
def decrypt_cipher(
self,
cipher_data: dict,
enc_key: bytes,
mac_key: bytes,
) -> Optional[dict]:
"""Decrypt a single cipher (vault item)."""
try:
# Decrypt name
name = ""
if cipher_data.get("name"):
try:
name = self.decrypt_cipher_string(
cipher_data["name"], enc_key, mac_key
).decode("utf-8")
except Exception:
name = "[encrypted]"
# Decrypt fields based on type
result = {
"id": cipher_data.get("id"),
"type": cipher_data.get("type"),
"name": name,
"folderId": cipher_data.get("folderId"),
"organizationId": cipher_data.get("organizationId"),
"favorite": cipher_data.get("favorite", False),
"revisionDate": cipher_data.get("revisionDate"),
}
cipher_type = cipher_data.get("type")
if cipher_type == 1: # Login
login = cipher_data.get("login", {})
result["login"] = {
"username": self._decrypt_field(
login.get("username"), enc_key, mac_key
),
"password": self._decrypt_field(
login.get("password"), enc_key, mac_key
),
"totp": self._decrypt_field(
login.get("totp"), enc_key, mac_key
),
"uris": [
{
"uri": self._decrypt_field(
u.get("uri"), enc_key, mac_key
),
"match": u.get("match"),
}
for u in login.get("uris", [])
],
}
elif cipher_type == 2: # Secure Note
result["notes"] = self._decrypt_field(
cipher_data.get("notes"), enc_key, mac_key
)
elif cipher_type == 3: # Card
card = cipher_data.get("card", {})
result["card"] = {
"cardholderName": self._decrypt_field(
card.get("cardholderName"), enc_key, mac_key
),
"brand": self._decrypt_field(
card.get("brand"), enc_key, mac_key
),
"number": self._decrypt_field(
card.get("number"), enc_key, mac_key
),
"expMonth": self._decrypt_field(
card.get("expMonth"), enc_key, mac_key
),
"expYear": self._decrypt_field(
card.get("expYear"), enc_key, mac_key
),
}
elif cipher_type == 4: # Identity
identity = cipher_data.get("identity", {})
result["identity"] = {
"firstName": self._decrypt_field(
identity.get("firstName"), enc_key, mac_key
),
"lastName": self._decrypt_field(
identity.get("lastName"), enc_key, mac_key
),
"email": self._decrypt_field(
identity.get("email"), enc_key, mac_key
),
"phone": self._decrypt_field(
identity.get("phone"), enc_key, mac_key
),
}
# Decrypt custom fields
fields = []
for field in cipher_data.get("fields", []):
fields.append({
"name": self._decrypt_field(
field.get("name"), enc_key, mac_key
),
"value": self._decrypt_field(
field.get("value"), enc_key, mac_key
),
"type": field.get("type"),
"hidden": field.get("hidden", False),
})
result["fields"] = fields
# Decrypt notes
if cipher_data.get("notes") and cipher_type != 2:
result["notes"] = self._decrypt_field(
cipher_data.get("notes"), enc_key, mac_key
)
return result
except Exception as e:
# Return partial result with error info
return {
"id": cipher_data.get("id"),
"type": cipher_data.get("type"),
"name": f"[decryption error: {str(e)}]",
"error": True,
}
def _decrypt_field(
self,
value: Optional[str],
enc_key: bytes,
mac_key: bytes,
) -> Optional[str]:
"""Decrypt a single field value."""
if not value:
return None
try:
decrypted = self.decrypt_cipher_string(value, enc_key, mac_key)
return decrypted.decode("utf-8")
except Exception:
return "[encrypted]"
# PIN-related crypto
class PinCrypto:
"""Handles PIN-based vault unlock (PasswordProtectedKeyEnvelope)."""
def __init__(self):
self.crypto = BitwardenCrypto()
def derive_pin_key(
self, pin: str, salt: str, kdf_iterations: int = 200000
) -> bytes:
"""Derive a key from the PIN using PBKDF2."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=ENCRYPTION_KEY_LENGTH,
salt=salt.encode("utf-8") if isinstance(salt, str) else salt,
iterations=kdf_iterations,
backend=default_backend(),
)
return kdf.derive(pin.encode("utf-8"))
def encrypt_user_key_for_pin(
self, user_key: bytes, pin_key: bytes
) -> Tuple[bytes, bytes]:
"""Encrypt the user key with the PIN-derived key. Returns (encrypted_key, iv)."""
iv = os.urandom(16)
cipher = Cipher(
algorithms.AES(pin_key), modes.CBC(iv), backend=default_backend()
)
encryptor = cipher.encryptor()
# Pad user key with PKCS7
padder = padding.PKCS7(128).padder()
padded = padder.update(user_key) + padder.finalize()
encrypted = encryptor.update(padded) + encryptor.finalize()
# Compute HMAC
mac = self.crypto.hmac_sha256(pin_key, iv + encrypted)
return iv + encrypted + mac, iv
def decrypt_user_key_with_pin(
self, encrypted_envelope: bytes, pin_key: bytes
) -> bytes:
"""Decrypt the user key using the PIN-derived key."""
# Envelope format: IV (16) + EncryptedData (32) + MAC (32)
if len(encrypted_envelope) < 80:
raise ValueError("Invalid PIN envelope length")
iv = encrypted_envelope[:16]
mac = encrypted_envelope[-32:]
ct = encrypted_envelope[16:-32]
# Verify HMAC
if not self.crypto.verify_mac(pin_key, iv + ct, mac):
raise ValueError("PIN MAC verification failed")
return self.crypto.decrypt_aes_cbc_256(pin_key, iv, ct)
+107
View File
@@ -0,0 +1,107 @@
"""
TOTP (Time-based One-Time Password) generation for Bitwarden vault items.
"""
import hashlib
import hmac
import struct
import time
from typing import Optional
# Try to use pyotp if available, otherwise use manual implementation
try:
import pyotp
HAS_PYOTP = True
except ImportError:
HAS_PYOTP = False
def generate_totp(secret: str, period: int = 30, digits: int = 6) -> str:
"""Generate a TOTP code from a secret."""
if not secret:
return ""
# Clean the secret (remove spaces, convert to uppercase)
secret = secret.replace(" ", "").upper()
# Try to parse as otpauth:// URI
if secret.startswith("otpauth://"):
parsed = _parse_otpauth_uri(secret)
if parsed:
secret = parsed["secret"]
period = parsed.get("period", period)
digits = parsed.get("digits", digits)
if HAS_PYOTP:
totp = pyotp.TOTP(secret, interval=period, digits=digits)
return totp.now()
return _generate_totp_manual(secret, period, digits)
def get_totp_remaining_seconds(period: int = 30) -> int:
"""Get seconds remaining until current TOTP code expires."""
return period - (int(time.time()) % period)
def _generate_totp_manual(secret: str, period: int, digits: int) -> str:
"""Manual TOTP implementation when pyotp is not available."""
# Decode base32 secret
_BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
secret = secret.upper()
# Remove padding
padding_needed = (8 - len(secret) % 8) % 8
secret += "=" * padding_needed
# Decode base32
binary = b""
for char in secret:
if char == "=":
continue
try:
val = _BASE32_CHARS.index(char)
except ValueError:
continue
binary += struct.pack(">B", val)
# Time counter
counter = int(time.time()) // period
counter_bytes = struct.pack(">Q", counter)
# HMAC-SHA1
hmac_result = hmac.new(binary, counter_bytes, hashlib.sha1).digest()
# Dynamic truncation
offset = hmac_result[-1] & 0x0F
truncated = struct.unpack(
">I", hmac_result[offset : offset + 4]
)[0]
truncated &= 0x7FFFFFFF
# Generate code
code = truncated % (10 ** digits)
return str(code).zfill(digits)
def _parse_otpauth_uri(uri: str) -> Optional[dict]:
"""Parse an otpauth:// URI."""
# otpauth://totp/Label?secret=XXX&issuer=XXX&period=30&digits=6
if not uri.startswith("otpauth://"):
return None
parts = uri.split("?", 1)
if len(parts) < 2:
return None
params = {}
for param in parts[1].split("&"):
key, _, value = param.partition("=")
params[key] = value
secret = params.get("secret", "")
return {
"secret": secret,
"issuer": params.get("issuer", ""),
"period": int(params.get("period", "30")),
"digits": int(params.get("digits", "6")),
}
+38
View File
@@ -0,0 +1,38 @@
"""
Type stubs for Decky plugin development.
"""
from typing import Any, Callable, Optional
class logger:
"""Plugin logger."""
@staticmethod
def info(msg: str) -> None: ...
@staticmethod
def warning(msg: str) -> None: ...
@staticmethod
def error(msg: str) -> None: ...
@staticmethod
def debug(msg: str) -> None: ...
class plugin:
"""Plugin decorator and utilities."""
@staticmethod
def call(method: str, *args: Any) -> Any: ...
# Plugin lifecycle methods
async def _main() -> None:
"""Called when plugin loads."""
pass
async def _unload() -> None:
"""Called when plugin unloads."""
pass
async def _migration() -> None:
"""Called on plugin version change."""
pass
+9
View File
@@ -0,0 +1,9 @@
{
"server_url": "https://api.bitwarden.com",
"email": "",
"pin_enabled": false,
"auto_lock_on_sleep": true,
"clipboard_clear_seconds": 60,
"require_password_on_restart": true,
"last_sync": null
}
+418
View File
@@ -0,0 +1,418 @@
"""
Decky Vaultwarden - Bitwarden/Vaultwarden password manager plugin for Decky Loader.
Main entry point for the Python backend. Provides API routes for the frontend.
"""
import json
import os
import time
from typing import Optional
import decky_plugin
from .bitwarden_client import BitwardenClient
from .crypto import BitwardenCrypto, PinCrypto
from .totp import generate_totp, get_totp_remaining_seconds
from .clipboard import copy_to_clipboard, clear_all_clipboards
class Plugin:
"""Main plugin class for Decky Vaultwarden."""
def __init__(self):
self.client: Optional[BitwardenClient] = None
self.pin_crypto = PinCrypto()
self.crypto = BitwardenCrypto()
self._vault_data: Optional[dict] = None
self._decrypted_vault: Optional[dict] = None
self._master_key: Optional[bytes] = None
self._enc_key: Optional[bytes] = None
self._mac_key: Optional[bytes] = None
self._session_active: bool = False
self._settings_path = os.path.join(
os.path.expanduser("~"), ".config", "decky-vaultwarden"
)
self._pin_attempts = 0
self._MAX_PIN_ATTEMPTS = 5
# ===== Lifecycle Methods =====
async def _main(self):
"""Called when the plugin is loaded."""
decky_plugin.logger.info("Decky Vaultwarden plugin loaded")
os.makedirs(self._settings_path, exist_ok=True)
async def _unload(self):
"""Called when the plugin is unloaded."""
await self.lock_vault()
decky_plugin.logger.info("Decky Vaultwarden plugin unloaded")
async def _migration(self):
"""Called when plugin version changes."""
pass
# ===== Authentication =====
async def login_password(
self, server_url: str, email: str, password: str, two_factor_token: Optional[str] = None
) -> dict:
"""Login with email and master password."""
try:
self.client = BitwardenClient(server_url)
result = await self.client.login_password(email, password, two_factor_token)
if result.get("two_factor_required"):
return {"two_factor_required": True}
if result.get("success"):
self._master_key = result.get("master_key")
self._enc_key = result.get("enc_key")
self._mac_key = result.get("mac_key")
self._session_active = True
self._save_settings({
"server_url": server_url,
"email": email,
"kdf_info": self.client.get_kdf_info(),
})
return {"success": True, "needs_unlock": result.get("enc_key") is None}
return {"success": False, "error": "Login failed"}
except Exception as e:
return {"success": False, "error": str(e)}
async def login_api_key(
self, server_url: str, client_id: str, client_secret: str, email: str
) -> dict:
"""Login with API key."""
try:
self.client = BitwardenClient(server_url)
result = await self.client.login_api_key(client_id, client_secret, email)
if result.get("success"):
self._session_active = True
self._save_settings({
"server_url": server_url,
"email": email,
"auth_method": "api_key",
"kdf_info": self.client.get_kdf_info(),
})
return {
"success": True,
"needs_master_password": True,
"message": "API key authenticated. Enter master password to unlock vault.",
}
return {"success": False, "error": result.get("error", "API key login failed")}
except Exception as e:
return {"success": False, "error": str(e)}
async def unlock_with_master_password(self, password: str) -> dict:
"""Unlock vault with master password (for API key users or fresh login)."""
try:
if not self.client:
return {"success": False, "error": "Not authenticated. Login first."}
result = await self.client.unlock_with_master_password(password)
self._master_key = result.get("master_key")
# Sync and decrypt vault
sync_data = await self.client.sync_vault()
self._vault_data = sync_data
# Get encryption keys from login or derive them
if not self._enc_key and self._master_key:
enc_user_key = sync_data.get("profile", {}).get("key")
if enc_user_key:
self._enc_key, self._mac_key = self.crypto.decrypt_user_key(
enc_user_key, self._master_key
)
if self._enc_key and self._mac_key:
self._decrypted_vault = self.client.decrypt_vault(
sync_data, self._enc_key, self._mac_key
)
return {"success": True, "vault_size": len(self._decrypted_vault.get("ciphers", []))}
return {"success": False, "error": "Could not derive encryption keys"}
except Exception as e:
return {"success": False, "error": str(e)}
# ===== Vault Operations =====
async def get_vault_items(self) -> dict:
"""Get decrypted vault items."""
if not self._decrypted_vault:
return {"success": False, "error": "Vault not unlocked"}
return {"success": True, "data": self._decrypted_vault}
async def get_folders(self) -> dict:
"""Get decrypted folders."""
if not self._decrypted_vault:
return {"success": False, "error": "Vault not unlocked"}
return {
"success": True,
"folders": self._decrypted_vault.get("folders", []),
}
async def search_vault(self, query: str) -> dict:
"""Search vault items by name, username, or URI."""
if not self._decrypted_vault:
return {"success": False, "error": "Vault not unlocked"}
query_lower = query.lower()
results = []
for cipher in self._decrypted_vault.get("ciphers", []):
if cipher.get("error"):
continue
# Search in name
name = cipher.get("name", "")
if query_lower in name.lower():
results.append(cipher)
continue
# Search in login fields
login = cipher.get("login", {})
username = login.get("username", "") or ""
if query_lower in username.lower():
results.append(cipher)
continue
# Search in URIs
for uri in login.get("uris", []):
uri_str = uri.get("uri", "") or ""
if query_lower in uri_str.lower():
results.append(cipher)
break
return {"success": True, "results": results}
# ===== Credential Copy =====
async def copy_password(self, cipher_id: str) -> dict:
"""Copy a password to clipboard with 60s auto-clear."""
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}")
async def copy_username(self, cipher_id: str) -> dict:
"""Copy a username to clipboard with 60s auto-clear."""
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}")
async def copy_totp(self, cipher_id: str) -> dict:
"""Copy TOTP code to clipboard with 60s auto-clear."""
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": False, "error": "TOTP not configured"}
async def get_totp_code(self, cipher_id: str) -> dict:
"""Get current TOTP code without copying."""
totp_secret = self._get_cipher_field(cipher_id, "totp")
if totp_secret:
code = generate_totp(totp_secret)
remaining = get_totp_remaining_seconds()
return {"success": True, "code": code, "remaining_seconds": remaining}
return {"success": False, "error": "TOTP not configured"}
# ===== PIN Management =====
async def setup_pin(self, pin: str) -> dict:
"""Set up PIN for vault unlock."""
try:
if not self._enc_key:
return {"success": False, "error": "Vault not unlocked. Login first."}
settings = self._load_settings()
kdf_info = settings.get("kdf_info", {})
email = settings.get("email", "")
# Generate salt for PIN key derivation
pin_salt = f"{email}:{pin}".lower()
# Derive PIN key
pin_key = self.pin_crypto.derive_pin_key(
pin, pin_salt, kdf_iterations=200000
)
# Encrypt user key with PIN key
envelope, iv = self.pin_crypto.encrypt_user_key_for_pin(
self._enc_key, pin_key
)
# Save PIN settings
settings["pin_enabled"] = True
settings["pin_salt"] = pin_salt
settings["pin_kdf_iterations"] = 200000
settings["pin_envelope"] = envelope.hex()
settings["pin_envelope_iv"] = iv.hex()
self._save_settings(settings)
self._pin_attempts = 0
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def unlock_with_pin(self, pin: str) -> dict:
"""Unlock vault using PIN."""
try:
settings = self._load_settings()
if not settings.get("pin_enabled"):
return {"success": False, "error": "PIN not configured"}
if self._pin_attempts >= self._MAX_PIN_ATTEMPTS:
return {"success": False, "error": "Too many failed attempts. Login with master password."}
pin_salt = settings.get("pin_salt", "")
pin_iterations = settings.get("pin_kdf_iterations", 200000)
envelope_hex = settings.get("pin_envelope", "")
if not envelope_hex or not pin_salt:
return {"success": False, "error": "Invalid PIN configuration"}
# Derive PIN key
pin_key = self.pin_crypto.derive_pin_key(
pin, pin_salt, kdf_iterations=pin_iterations
)
# Decrypt user key from envelope
envelope = bytes.fromhex(envelope_hex)
user_key = self.pin_crypto.decrypt_user_key_with_pin(envelope, pin_key)
if len(user_key) == 64:
self._enc_key = user_key[:32]
self._mac_key = user_key[32:]
elif len(user_key) == 32:
self._enc_key = user_key
self._mac_key = user_key
else:
raise ValueError("Invalid decrypted user key length")
# Re-sync and decrypt vault
if self.client:
sync_data = await self.client.sync_vault()
self._vault_data = sync_data
self._decrypted_vault = self.client.decrypt_vault(
sync_data, self._enc_key, self._mac_key
)
self._session_active = True
self._pin_attempts = 0
return {"success": True, "vault_size": len(self._decrypted_vault.get("ciphers", []))}
return {"success": False, "error": "No active session"}
except Exception as e:
self._pin_attempts += 1
remaining = self._MAX_PIN_ATTEMPTS - self._pin_attempts
return {
"success": False,
"error": f"Invalid PIN. {remaining} attempts remaining.",
"attempts_remaining": remaining,
}
async def remove_pin(self) -> dict:
"""Remove PIN configuration."""
settings = self._load_settings()
settings["pin_enabled"] = False
settings.pop("pin_salt", None)
settings.pop("pin_kdf_iterations", None)
settings.pop("pin_envelope", None)
settings.pop("pin_envelope_iv", None)
self._save_settings(settings)
return {"success": True}
async def is_pin_enabled(self) -> dict:
"""Check if PIN is configured."""
settings = self._load_settings()
return {"enabled": settings.get("pin_enabled", False)}
# ===== Vault Lock =====
async def lock_vault(self) -> dict:
"""Lock the vault and clear sensitive data."""
await clear_all_clipboards()
self._decrypted_vault = None
self._vault_data = None
self._enc_key = None
self._mac_key = None
self._session_active = False
self._pin_attempts = 0
return {"success": True}
async def logout(self) -> dict:
"""Full logout - clear all data."""
await self.lock_vault()
self._master_key = None
self.client = None
return {"success": True}
# ===== Settings =====
async def get_settings(self) -> dict:
"""Get plugin settings."""
return {"success": True, "settings": self._load_settings()}
async def update_settings(self, new_settings: dict) -> dict:
"""Update plugin settings."""
settings = self._load_settings()
settings.update(new_settings)
self._save_settings(settings)
return {"success": True}
async def get_status(self) -> dict:
"""Get current plugin status."""
return {
"session_active": self._session_active,
"vault_loaded": self._decrypted_vault is not None,
"cipher_count": len(self._decrypted_vault.get("ciphers", [])) if self._decrypted_vault else 0,
"folder_count": len(self._decrypted_vault.get("folders", [])) if self._decrypted_vault else 0,
}
# ===== Internal Helpers =====
def _get_cipher_field(self, cipher_id: str, field: str):
"""Get a field from a decrypted cipher."""
if not self._decrypted_vault:
return None
for cipher in self._decrypted_vault.get("ciphers", []):
if cipher.get("id") == cipher_id:
if field == "password":
return cipher.get("login", {}).get("password")
elif field == "username":
return cipher.get("login", {}).get("username")
elif field == "totp":
return cipher.get("login", {}).get("totp")
elif field == "name":
return cipher.get("name")
return None
return None
def _load_settings(self) -> dict:
"""Load settings from disk."""
settings_file = os.path.join(self._settings_path, "settings.json")
try:
with open(settings_file, "r") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_settings(self, settings: dict):
"""Save settings to disk."""
os.makedirs(self._settings_path, exist_ok=True)
settings_file = os.path.join(self._settings_path, "settings.json")
with open(settings_file, "w") as f:
json.dump(settings, f, indent=2)
+2210
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "decky-vaultwarden",
"version": "1.0.0",
"description": "Bitwarden/Vaultwarden password manager plugin for Decky Loader",
"type": "module",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@decky/api": "^1.0.0",
"@decky/ui": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@decky/rollup": "^1.0.0",
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-typescript": "^11.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"rollup": "^4.0.0",
"rollup-plugin-import-css": "^3.5.0",
"tslib": "^2.6.0",
"typescript": "^5.3.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "Vaultwarden",
"author": "JustZoe101",
"flags": ["needs_community_python"],
"api_version": 1,
"content": {
"name": "Vaultwarden - Password Manager",
"description": "Access your Bitwarden or Vaultwarden passwords directly from the Steam Deck. Copy passwords, usernames, and TOTP codes to clipboard with a 60-second auto-clear.",
"version": "1.0.0",
"tags": ["security", "passwords", "bitwarden", "vaultwarden"],
"pic_value": "https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/login-logo.png"
}
}
+4
View File
@@ -0,0 +1,4 @@
aiohttp>=3.9.0
pyotp>=2.9.0
cryptography>=42.0.0
argon2-cffi>=23.1.0
+5
View File
@@ -0,0 +1,5 @@
import deckyPlugin from "@decky/rollup";
export default deckyPlugin({
// Extra rollup options if needed
});
+192
View File
@@ -0,0 +1,192 @@
/**
* Frontend <-> Backend API bridge.
* Calls Python backend methods via Decky's call function.
*/
import { call } from "@decky/api";
// Types
export interface VaultItem {
id: string;
type: number;
name: string;
folderId?: string;
organizationId?: string;
favorite?: boolean;
revisionDate?: string;
login?: {
username?: string;
password?: string;
totp?: string;
uris?: { uri?: string; match?: number }[];
};
notes?: string;
card?: {
cardholderName?: string;
brand?: string;
number?: string;
expMonth?: string;
expYear?: string;
};
identity?: {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
};
fields?: { name?: string; value?: string; type?: number; hidden?: boolean }[];
error?: boolean;
}
export interface Folder {
id: string;
name: string;
revisionDate?: string;
}
export interface VaultData {
ciphers: VaultItem[];
folders: Folder[];
folder_map: Record<string, string>;
collections: any[];
}
export interface LoginResult {
success?: boolean;
two_factor_required?: boolean;
needs_unlock?: boolean;
error?: string;
}
export interface CopyResult {
success?: boolean;
clear_after?: number;
expires_at?: number;
remaining_seconds?: number;
error?: string;
}
export interface ApiKeyLoginResult {
success?: boolean;
needs_master_password?: boolean;
message?: string;
error?: string;
}
// API wrapper using new Decky call API
class VaultApi {
private async call<T>(method: string, ...args: any[]): Promise<T> {
try {
const result = await call(method, ...args);
return result as T;
} catch (error: any) {
throw new Error(error.message || "Backend error");
}
}
// Authentication
async loginPassword(
serverUrl: string,
email: string,
password: string,
twoFactorToken?: string
): Promise<LoginResult> {
return this.call<LoginResult>(
"login_password",
serverUrl,
email,
password,
twoFactorToken
);
}
async loginApiKey(
serverUrl: string,
clientId: string,
clientSecret: string,
email: string
): Promise<ApiKeyLoginResult> {
return this.call<ApiKeyLoginResult>(
"login_api_key",
serverUrl,
clientId,
clientSecret,
email
);
}
async unlockWithMasterPassword(password: string): Promise<LoginResult> {
return this.call<LoginResult>("unlock_with_master_password", password);
}
// Vault
async getVaultItems(): Promise<{ success: boolean; data: VaultData }> {
return this.call("get_vault_items");
}
async searchVault(query: string): Promise<{ success: boolean; results: VaultItem[] }> {
return this.call("search_vault", query);
}
// Credential copy
async copyPassword(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_password", cipherId);
}
async copyUsername(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_username", cipherId);
}
async copyTotp(cipherId: string): Promise<CopyResult> {
return this.call<CopyResult>("copy_totp", cipherId);
}
async getTotpCode(cipherId: string): Promise<{ success: boolean; code?: string; remaining_seconds?: number; error?: string }> {
return this.call("get_totp_code", cipherId);
}
// PIN
async setupPin(pin: string): Promise<{ success: boolean; error?: string }> {
return this.call("setup_pin", pin);
}
async unlockWithPin(pin: string): Promise<LoginResult> {
return this.call<LoginResult>("unlock_with_pin", pin);
}
async removePin(): Promise<{ success: boolean }> {
return this.call("remove_pin");
}
async isPinEnabled(): Promise<{ enabled: boolean }> {
return this.call("is_pin_enabled");
}
// Session
async lockVault(): Promise<{ success: boolean }> {
return this.call("lock_vault");
}
async logout(): Promise<{ success: boolean }> {
return this.call("logout");
}
async getStatus(): Promise<{
session_active: boolean;
vault_loaded: boolean;
cipher_count: number;
folder_count: number;
}> {
return this.call("get_status");
}
// Settings
async getSettings(): Promise<{ success: boolean; settings: any }> {
return this.call("get_settings");
}
async updateSettings(settings: any): Promise<{ success: boolean }> {
return this.call("update_settings", settings);
}
}
export const vaultApi = new VaultApi();
+303
View File
@@ -0,0 +1,303 @@
import { useState, useEffect } from "react";
import { ButtonItem } from "@decky/ui";
import { VaultItem } from "../api/backend";
interface ItemCardProps {
item: VaultItem;
folderMap: Record<string, string>;
copyStatus: { field: string; cipherId: string; expiresAt: number } | null;
onCopyPassword: (cipherId: string) => Promise<boolean>;
onCopyUsername: (cipherId: string) => Promise<boolean>;
onCopyTotp: (cipherId: string) => Promise<boolean>;
}
const CIPHER_TYPE_NAMES: Record<number, string> = {
1: "Login",
2: "Secure Note",
3: "Card",
4: "Identity",
};
const CIPHER_TYPE_ICONS: Record<number, string> = {
1: "\uD83D\uDD11", // key
2: "\uD83D\uDCDD", // note
3: "\uD83D\uDCB3", // card
4: "\uD83D\uDC64", // person
};
export function ItemCard({
item,
folderMap,
copyStatus,
onCopyPassword,
onCopyUsername,
onCopyTotp,
}: ItemCardProps) {
const [expanded, setExpanded] = useState(false);
const [copying, setCopying] = useState<string | null>(null);
const isCopied = (field: string) =>
copyStatus?.cipherId === item.id && copyStatus?.field === field;
const getCopyExpiresIn = () => {
if (!copyStatus) return 0;
return Math.max(0, Math.ceil(copyStatus.expiresAt - Date.now() / 1000));
};
const [expiresIn, setExpiresIn] = useState(getCopyExpiresIn());
useEffect(() => {
const interval = setInterval(() => {
setExpiresIn(getCopyExpiresIn());
}, 1000);
return () => clearInterval(interval);
}, [copyStatus]);
const handleCopy = async (field: string) => {
setCopying(field);
try {
switch (field) {
case "password":
await onCopyPassword(item.id);
break;
case "username":
await onCopyUsername(item.id);
break;
case "totp":
await onCopyTotp(item.id);
break;
}
} finally {
setCopying(null);
}
};
const folderName = item.folderId ? folderMap[item.folderId] : null;
return (
<div
style={{
background: "#1e1e1e",
borderRadius: "8px",
padding: "12px",
marginBottom: "8px",
border: expanded ? "1px solid #1a9fff" : "1px solid #333",
}}
>
{/* Header */}
<div
onClick={() => setExpanded(!expanded)}
style={{
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
>
<span style={{ fontSize: "18px" }}>
{CIPHER_TYPE_ICONS[item.type] || "\uD83D\uDD12"}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: "bold",
fontSize: "13px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.name || "[unnamed]"}
</div>
{item.login?.username && (
<div
style={{
fontSize: "11px",
color: "#888",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.login.username}
</div>
)}
</div>
{folderName && (
<span
style={{
fontSize: "10px",
color: "#666",
background: "#2a2a2a",
padding: "2px 6px",
borderRadius: "4px",
}}
>
{folderName}
</span>
)}
<span style={{ fontSize: "10px", color: "#666" }}>
{expanded ? "\u25B2" : "\u25BC"}
</span>
</div>
{/* Expanded content */}
{expanded && item.login && (
<div style={{ marginTop: "12px", display: "flex", flexDirection: "column", gap: "6px" }}>
{/* URI */}
{item.login.uris && item.login.uris.length > 0 && (
<div
style={{
fontSize: "10px",
color: "#666",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.login.uris[0].uri || ""}
</div>
)}
{/* Copy buttons */}
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
{item.login.username && (
<CopyButton
label="Username"
field="username"
isCopied={isCopied("username")}
expiresIn={isCopied("username") ? expiresIn : 0}
loading={copying === "username"}
onClick={() => handleCopy("username")}
/>
)}
{item.login.password && (
<CopyButton
label="Password"
field="password"
isCopied={isCopied("password")}
expiresIn={isCopied("password") ? expiresIn : 0}
loading={copying === "password"}
onClick={() => handleCopy("password")}
/>
)}
{item.login.totp && (
<CopyButton
label="TOTP"
field="totp"
isCopied={isCopied("totp")}
expiresIn={isCopied("totp") ? expiresIn : 0}
loading={copying === "totp"}
onClick={() => handleCopy("totp")}
/>
)}
</div>
{/* Custom fields */}
{item.fields && item.fields.length > 0 && (
<div
style={{
marginTop: "8px",
padding: "8px",
background: "#2a2a2a",
borderRadius: "4px",
}}
>
<div style={{ fontSize: "10px", color: "#888", marginBottom: "4px" }}>
Custom Fields
</div>
{item.fields.map((field, idx) => (
<div key={idx} style={{ fontSize: "11px", marginBottom: "2px" }}>
<span style={{ color: "#888" }}>{field.name}: </span>
<span style={{ color: "#ccc" }}>
{field.hidden ? "••••••••" : field.value || ""}
</span>
</div>
))}
</div>
)}
{/* Notes */}
{item.notes && (
<div
style={{
marginTop: "8px",
padding: "8px",
background: "#2a2a2a",
borderRadius: "4px",
fontSize: "11px",
color: "#888",
maxHeight: "60px",
overflow: "hidden",
}}
>
{item.notes}
</div>
)}
</div>
)}
{/* Card info */}
{expanded && item.card && (
<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>
)}
{item.card.expMonth && item.card.expYear && (
<div>
Expires: {item.card.expMonth}/{item.card.expYear}
</div>
)}
{item.card.cardholderName && (
<div>Holder: {item.card.cardholderName}</div>
)}
</div>
)}
</div>
);
}
function CopyButton({
label,
field,
isCopied,
expiresIn,
loading,
onClick,
}: {
label: string;
field: string;
isCopied: boolean;
expiresIn: number;
loading: boolean;
onClick: () => void;
}) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
disabled={loading}
style={{
background: isCopied ? "#2ecc71" : "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "4px",
padding: "6px 10px",
fontSize: "11px",
cursor: loading ? "wait" : "pointer",
minWidth: "70px",
}}
>
{loading
? "..."
: isCopied
? `${expiresIn}s`
: label}
</button>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import {
PanelSection,
TextField,
ButtonItem,
ToggleField,
Spinner,
} from "@decky/ui";
interface LoginViewProps {
onLogin: (
serverUrl: string,
email: string,
password: string,
twoFactorToken?: string
) => Promise<boolean>;
onApiKeyLogin: (
serverUrl: string,
clientId: string,
clientSecret: string,
email: string
) => Promise<boolean>;
loading: boolean;
error: string | null;
twoFactorRequired?: boolean;
needsMasterPassword?: boolean;
}
export function LoginView({
onLogin,
onApiKeyLogin,
loading,
error,
twoFactorRequired,
needsMasterPassword,
}: LoginViewProps) {
const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password");
const [serverUrl, setServerUrl] = useState("https://api.bitwarden.com");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [twoFactorCode, setTwoFactorCode] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const handlePasswordLogin = async () => {
if (!email || !password) return;
await onLogin(serverUrl, email, password, twoFactorCode || undefined);
};
const handleApiKeyLogin = async () => {
if (!clientId || !clientSecret || !email) return;
await onApiKeyLogin(serverUrl, clientId, clientSecret, email);
};
return (
<PanelSection title="Vaultwarden Login">
{/* Server URL */}
<TextField
label="Server URL"
description="Bitwarden cloud: https://api.bitwarden.com | Vaultwarden: https://your-domain.com"
value={serverUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setServerUrl(e.target.value)}
/>
{/* Auth Method Toggle */}
<ToggleField
label="Use API Key"
description="Toggle between password login and API key authentication"
checked={authMethod === "apikey"}
onChange={(v) => setAuthMethod(v ? "apikey" : "password")}
/>
{/* Email (common to both methods) */}
<TextField
label="Email"
value={email}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
/>
{authMethod === "password" ? (
<>
<TextField
label="Master Password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
{twoFactorRequired && (
<TextField
label="Two-Factor Code"
description="Enter your 2FA code from authenticator app"
value={twoFactorCode}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTwoFactorCode(e.target.value)}
/>
)}
<ButtonItem
layout="below"
onClick={handlePasswordLogin}
disabled={loading || !email || !password}
>
{loading ? <Spinner width={16} height={16} /> : "Login with Password"}
</ButtonItem>
</>
) : (
<>
<TextField
label="Client ID"
description="From Vaultwarden Settings > Security > Keys > View API Key"
value={clientId}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientId(e.target.value)}
/>
<TextField
label="Client Secret"
value={clientSecret}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setClientSecret(e.target.value)}
/>
<ButtonItem
layout="below"
onClick={handleApiKeyLogin}
disabled={loading || !clientId || !clientSecret || !email}
>
{loading ? <Spinner width={16} height={16} /> : "Login with API Key"}
</ButtonItem>
</>
)}
{error && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{error}
</div>
)}
</PanelSection>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useState } from "react";
import { TextField, Spinner } from "@decky/ui";
interface PinSetupModalProps {
isOpen: boolean;
onClose: () => void;
onSetupPin: (pin: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}
export function PinSetupModal({
isOpen,
onClose,
onSetupPin,
loading,
error,
}: PinSetupModalProps) {
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
if (!isOpen) return null;
const handleSubmit = async () => {
setLocalError(null);
if (pin.length < 4) {
setLocalError("PIN must be at least 4 characters");
return;
}
if (pin !== confirmPin) {
setLocalError("PINs do not match");
return;
}
const success = await onSetupPin(pin);
if (success) {
setPin("");
setConfirmPin("");
onClose();
}
};
return (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.7)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={onClose}
>
<div
style={{
background: "#1e1e1e",
borderRadius: "12px",
padding: "24px",
width: "320px",
maxHeight: "80vh",
overflow: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 16px 0", color: "#fff" }}>Set Up PIN</h3>
<p style={{ fontSize: "12px", color: "#888", marginBottom: "16px" }}>
A PIN allows you to unlock your vault without entering your master
password each time. The PIN protects your encryption key locally.
</p>
<TextField
label="New PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
<TextField
label="Confirm PIN"
value={confirmPin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPin(e.target.value)}
/>
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
<button
onClick={onClose}
style={{
flex: 1,
padding: "10px",
background: "#333",
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: "pointer",
}}
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={loading || pin.length < 4}
style={{
flex: 1,
padding: "10px",
background: "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "6px",
cursor: loading ? "wait" : "pointer",
}}
>
{loading ? <Spinner width={16} height={16} /> : "Set PIN"}
</button>
</div>
{(localError || error) && (
<div
style={{
color: "#ff4444",
padding: "8px",
fontSize: "12px",
marginTop: "8px",
}}
>
{localError || error}
</div>
)}
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { useState } from "react";
import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinSetupViewProps {
onSetupPin: (pin: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}
export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) {
const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState("");
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async () => {
setLocalError(null);
if (pin.length < 4) {
setLocalError("PIN must be at least 4 characters");
return;
}
if (pin !== confirmPin) {
setLocalError("PINs do not match");
return;
}
const success = await onSetupPin(pin);
if (success) {
setPin("");
setConfirmPin("");
}
};
return (
<PanelSection title="Set Up PIN">
<div style={{ marginBottom: "12px", fontSize: "12px", color: "#888" }}>
A PIN allows you to unlock your vault without entering your master password
each time. The PIN protects your encryption key locally.
</div>
<TextField
label="New PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
<TextField
label="Confirm PIN"
value={confirmPin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPin(e.target.value)}
/>
<ButtonItem
layout="below"
onClick={handleSubmit}
disabled={loading || pin.length < 4}
>
{loading ? <Spinner width={16} height={16} /> : "Set Up PIN"}
</ButtonItem>
{(localError || error) && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{localError || error}
</div>
)}
</PanelSection>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinUnlockViewProps {
onUnlock: (pin: string) => Promise<boolean>;
onLogout: () => Promise<void>;
loading: boolean;
error: string | null;
}
export function PinUnlockView({
onUnlock,
onLogout,
loading,
error,
}: PinUnlockViewProps) {
const [pin, setPin] = useState("");
const handleUnlock = async () => {
if (!pin) return;
await onUnlock(pin);
setPin("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleUnlock();
}
};
return (
<PanelSection title="Vault Locked">
<div
style={{
textAlign: "center",
padding: "16px",
marginBottom: "12px",
}}
>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>&#128274;</div>
<div style={{ fontSize: "14px", color: "#ccc" }}>
Enter your PIN to unlock
</div>
</div>
<div onKeyDown={handleKeyDown}>
<TextField
label="PIN"
value={pin}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPin(e.target.value)}
/>
</div>
<ButtonItem
layout="below"
onClick={handleUnlock}
disabled={loading || !pin}
>
{loading ? <Spinner width={16} height={16} /> : "Unlock"}
</ButtonItem>
<ButtonItem layout="below" onClick={onLogout} disabled={loading}>
Login with Master Password
</ButtonItem>
{error && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{error}
</div>
)}
</PanelSection>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useState, useEffect } from "react";
import {
PanelSection,
TextField,
ButtonItem,
Spinner,
} from "@decky/ui";
import { VaultData, VaultItem, Folder } from "../api/backend";
import { ItemCard } from "./ItemCard";
interface VaultBrowserProps {
vaultData: VaultData;
searchQuery: string;
searchResults: VaultItem[] | null;
copyStatus: { field: string; cipherId: string; expiresAt: number } | null;
onSearch: (query: string) => void;
onCopyPassword: (cipherId: string) => Promise<boolean>;
onCopyUsername: (cipherId: string) => Promise<boolean>;
onCopyTotp: (cipherId: string) => Promise<boolean>;
onLock: () => void;
onSetupPin: () => void;
}
export function VaultBrowser({
vaultData,
searchQuery,
searchResults,
copyStatus,
onSearch,
onCopyPassword,
onCopyUsername,
onCopyTotp,
onLock,
onSetupPin,
}: VaultBrowserProps) {
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [localSearch, setLocalSearch] = useState(searchQuery);
// Get items for current view
const getDisplayedItems = (): VaultItem[] => {
if (searchResults) {
return searchResults;
}
let items = vaultData.ciphers.filter((c) => !c.error);
if (selectedFolder) {
items = items.filter((c) => c.folderId === selectedFolder);
}
// Sort by name
items.sort((a, b) => (a.name || "").localeCompare(b.name || ""));
return items;
};
const displayedItems = getDisplayedItems();
const handleSearchChange = (value: string) => {
setLocalSearch(value);
onSearch(value);
};
return (
<PanelSection title="Vault">
{/* Search */}
<TextField
label="Search"
description={`${displayedItems.length} items`}
value={localSearch}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearchChange(e.target.value)}
/>
{/* Folder tabs */}
{!searchQuery && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "4px",
marginBottom: "8px",
}}
>
<FolderTab
name="All"
count={vaultData.ciphers.filter((c) => !c.error).length}
selected={selectedFolder === null}
onClick={() => setSelectedFolder(null)}
/>
{vaultData.folders.map((folder) => (
<FolderTab
key={folder.id}
name={folder.name}
count={vaultData.ciphers.filter(
(c) => c.folderId === folder.id && !c.error
).length}
selected={selectedFolder === folder.id}
onClick={() => setSelectedFolder(folder.id)}
/>
))}
</div>
)}
{/* Items */}
<div style={{ maxHeight: "400px", overflowY: "auto" }}>
{displayedItems.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "24px",
color: "#888",
}}
>
{searchQuery ? "No items found" : "No items in this folder"}
</div>
) : (
displayedItems.map((item) => (
<ItemCard
key={item.id}
item={item}
folderMap={vaultData.folder_map}
copyStatus={copyStatus}
onCopyPassword={onCopyPassword}
onCopyUsername={onCopyUsername}
onCopyTotp={onCopyTotp}
/>
))
)}
</div>
{/* Actions */}
<ButtonItem layout="below" onClick={onLock}>
Lock Vault
</ButtonItem>
<ButtonItem layout="below" onClick={onSetupPin}>
Set Up PIN
</ButtonItem>
</PanelSection>
);
}
function FolderTab({
name,
count,
selected,
onClick,
}: {
name: string;
count: number;
selected: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
style={{
background: selected ? "#1a9fff" : "#333",
color: selected ? "#fff" : "#ccc",
border: "none",
borderRadius: "4px",
padding: "4px 8px",
fontSize: "11px",
cursor: "pointer",
whiteSpace: "nowrap",
}}
>
{name} ({count})
</button>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { useState, useCallback, useEffect } from "react";
import { vaultApi, VaultData, VaultItem } from "../api/backend";
export type VaultState =
| "logged_out"
| "two_factor_required"
| "needs_master_password"
| "pin_only"
| "loading"
| "unlocked";
export function useVault() {
const [state, setState] = useState<VaultState>("logged_out");
const [vaultData, setVaultData] = useState<VaultData | null>(null);
const [error, setError] = useState<string | null>(null);
const [pinEnabled, setPinEnabled] = useState(false);
const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<VaultItem[] | null>(null);
const [copyStatus, setCopyStatus] = useState<{
field: string;
cipherId: string;
expiresAt: number;
} | null>(null);
// Check initial status
useEffect(() => {
checkStatus();
}, []);
const checkStatus = async () => {
try {
const status = await vaultApi.getStatus();
const pin = await vaultApi.isPinEnabled();
setPinEnabled(pin.enabled);
if (status.vault_loaded) {
setState("unlocked");
const result = await vaultApi.getVaultItems();
if (result.success) {
setVaultData(result.data);
}
} else if (status.session_active) {
setState("needs_master_password");
} else if (pin.enabled) {
setState("pin_only");
} else {
setState("logged_out");
}
} catch (e) {
setState("logged_out");
}
};
const loginPassword = useCallback(
async (serverUrl: string, email: string, password: string, twoFactorToken?: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.loginPassword(
serverUrl,
email,
password,
twoFactorToken
);
if (result.two_factor_required) {
setState("two_factor_required");
return false;
}
if (result.needs_unlock) {
// Need to unlock with password
const unlockResult = await vaultApi.unlockWithMasterPassword(password);
if (unlockResult.success) {
await loadVault();
return true;
}
}
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "Login failed");
setState("logged_out");
return false;
} catch (e: any) {
setError(e.message);
setState("logged_out");
return false;
}
},
[]
);
const loginApiKey = useCallback(
async (serverUrl: string, clientId: string, clientSecret: string, email: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.loginApiKey(
serverUrl,
clientId,
clientSecret,
email
);
if (result.needs_master_password) {
setState("needs_master_password");
return true;
}
if (result.success) {
setState("needs_master_password");
return true;
}
setError(result.error || "API key login failed");
setState("logged_out");
return false;
} catch (e: any) {
setError(e.message);
setState("logged_out");
return false;
}
},
[]
);
const unlockWithPassword = useCallback(async (password: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.unlockWithMasterPassword(password);
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "Unlock failed");
setState("needs_master_password");
return false;
} catch (e: any) {
setError(e.message);
setState("needs_master_password");
return false;
}
}, []);
const unlockWithPin = useCallback(async (pin: string) => {
setState("loading");
setError(null);
try {
const result = await vaultApi.unlockWithPin(pin);
if (result.success) {
await loadVault();
return true;
}
setError(result.error || "PIN unlock failed");
setState("pin_only");
return false;
} catch (e: any) {
setError(e.message);
setState("pin_only");
return false;
}
}, []);
const loadVault = async () => {
try {
const result = await vaultApi.getVaultItems();
if (result.success) {
setVaultData(result.data);
setState("unlocked");
}
} catch (e: any) {
setError(e.message);
}
};
const search = useCallback(async (query: string) => {
setSearchQuery(query);
if (!query.trim()) {
setSearchResults(null);
return;
}
try {
const result = await vaultApi.searchVault(query);
if (result.success) {
setSearchResults(result.results);
}
} catch (e: any) {
setError(e.message);
}
}, []);
const copyPassword = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyPassword(cipherId);
if (result.success) {
setCopyStatus({
field: "password",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
});
setTimeout(() => setCopyStatus(null), 60000);
return true;
}
setError(result.error || "Failed to copy");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const copyUsername = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyUsername(cipherId);
if (result.success) {
setCopyStatus({
field: "username",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 60,
});
setTimeout(() => setCopyStatus(null), 60000);
return true;
}
setError(result.error || "Failed to copy");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const copyTotp = useCallback(async (cipherId: string) => {
try {
const result = await vaultApi.copyTotp(cipherId);
if (result.success) {
setCopyStatus({
field: "totp",
cipherId,
expiresAt: result.expires_at || Date.now() / 1000 + 30,
});
setTimeout(() => setCopyStatus(null), 30000);
return true;
}
setError(result.error || "Failed to copy TOTP");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const lockVault = useCallback(async () => {
await vaultApi.lockVault();
setVaultData(null);
setCopyStatus(null);
setSearchResults(null);
setSearchQuery("");
const pin = await vaultApi.isPinEnabled();
setPinEnabled(pin.enabled);
setState(pin.enabled ? "pin_only" : "logged_out");
}, []);
const logout = useCallback(async () => {
await vaultApi.logout();
setVaultData(null);
setCopyStatus(null);
setSearchResults(null);
setSearchQuery("");
setPinEnabled(false);
setState("logged_out");
}, []);
const setupPin = useCallback(async (pin: string) => {
try {
const result = await vaultApi.setupPin(pin);
if (result.success) {
setPinEnabled(true);
return true;
}
setError(result.error || "Failed to set up PIN");
return false;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
const removePin = useCallback(async () => {
try {
await vaultApi.removePin();
setPinEnabled(false);
return true;
} catch (e: any) {
setError(e.message);
return false;
}
}, []);
return {
state,
vaultData,
error,
pinEnabled,
selectedFolder,
searchQuery,
searchResults,
copyStatus,
setSelectedFolder,
loginPassword,
loginApiKey,
unlockWithPassword,
unlockWithPin,
search,
copyPassword,
copyUsername,
copyTotp,
lockVault,
logout,
setupPin,
removePin,
clearError: () => setError(null),
};
}
+223
View File
@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { definePlugin } from "@decky/api";
import { PanelSection, Spinner, TextField } from "@decky/ui";
import { useVault } from "./hooks/useVault";
import { LoginView } from "./components/LoginView";
import { PinUnlockView } from "./components/PinUnlockView";
import { VaultBrowser } from "./components/VaultBrowser";
import { PinSetupModal } from "./components/PinSetupModal";
function VaultwardenPlugin() {
const vault = useVault();
const [showPinSetup, setShowPinSetup] = useState(false);
// Handle keyboard input for PIN
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Lock vault on Ctrl+Shift+L
if (e.ctrlKey && e.shiftKey && e.key === "L") {
vault.lockVault();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [vault.lockVault]);
// Main render
if (vault.state === "loading") {
return (
<PanelSection title="Vaultwarden">
<div style={{ textAlign: "center", padding: "24px" }}>
<Spinner width={32} height={32} />
<div style={{ marginTop: "12px", color: "#888" }}>Loading...</div>
</div>
</PanelSection>
);
}
if (vault.state === "logged_out") {
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "two_factor_required") {
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
twoFactorRequired
/>
);
}
if (vault.state === "needs_master_password") {
return (
<MasterPasswordPrompt
onSubmit={vault.unlockWithPassword}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "pin_only") {
return (
<PinUnlockView
onUnlock={vault.unlockWithPin}
onLogout={vault.logout}
loading={false}
error={vault.error}
/>
);
}
if (vault.state === "unlocked" && vault.vaultData) {
return (
<>
<VaultBrowser
vaultData={vault.vaultData}
searchQuery={vault.searchQuery}
searchResults={vault.searchResults}
copyStatus={vault.copyStatus}
onSearch={vault.search}
onCopyPassword={vault.copyPassword}
onCopyUsername={vault.copyUsername}
onCopyTotp={vault.copyTotp}
onLock={vault.lockVault}
onSetupPin={() => setShowPinSetup(true)}
/>
<PinSetupModal
isOpen={showPinSetup}
onClose={() => setShowPinSetup(false)}
onSetupPin={vault.setupPin}
loading={false}
error={vault.error}
/>
</>
);
}
return (
<LoginView
onLogin={vault.loginPassword}
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
/>
);
}
function MasterPasswordPrompt({
onSubmit,
loading,
error,
}: {
onSubmit: (password: string) => Promise<boolean>;
loading: boolean;
error: string | null;
}) {
const [password, setPassword] = useState("");
const handleSubmit = async () => {
if (!password) return;
await onSubmit(password);
setPassword("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSubmit();
}
};
return (
<PanelSection title="Unlock Vault">
<div
style={{
textAlign: "center",
padding: "16px",
marginBottom: "12px",
}}
>
<div style={{ fontSize: "24px", marginBottom: "8px" }}>&#128272;</div>
<div style={{ fontSize: "14px", color: "#ccc" }}>
Enter your master password to unlock
</div>
</div>
<div onKeyDown={handleKeyDown}>
<TextField
label="Master Password"
value={password}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
/>
</div>
<button
onClick={handleSubmit}
disabled={loading || !password}
style={{
width: "100%",
padding: "10px",
background: "#1a9fff",
color: "#fff",
border: "none",
borderRadius: "6px",
fontSize: "14px",
cursor: loading ? "wait" : "pointer",
marginTop: "8px",
}}
>
{loading ? "Unlocking..." : "Unlock"}
</button>
{error && (
<div
style={{
color: "#ff4444",
padding: "8px",
fontSize: "12px",
marginTop: "8px",
}}
>
{error}
</div>
)}
</PanelSection>
);
}
export default definePlugin(() => ({
name: "Vaultwarden",
icon: (
<svg
viewBox="0 0 100 100"
style={{
width: "24px",
height: "24px",
fill: "#1a9fff",
}}
>
<path d="M50 5 L90 25 L90 75 L50 95 L10 75 L10 25 Z" />
<path
d="M50 20 L75 35 L75 65 L50 80 L25 65 L25 35 Z"
fill="#fff"
/>
</svg>
),
content: <VaultwardenPlugin />,
onDismount: () => {
console.log("Vaultwarden plugin dismounted");
},
}));
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "node",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"declaration": true,
"sourceMap": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "dist"]
}