chore: init decky vaultwarden plugin scaffold
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
out/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.env
|
||||
*.egg-info/
|
||||
.venv/
|
||||
venv/
|
||||
Vendored
+34
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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")),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
Generated
+2210
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
aiohttp>=3.9.0
|
||||
pyotp>=2.9.0
|
||||
cryptography>=42.0.0
|
||||
argon2-cffi>=23.1.0
|
||||
@@ -0,0 +1,5 @@
|
||||
import deckyPlugin from "@decky/rollup";
|
||||
|
||||
export default deckyPlugin({
|
||||
// Extra rollup options if needed
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" }}>🔒</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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" }}>🔐</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");
|
||||
},
|
||||
}));
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user