From 3bb5c5b1dec5f6e5080f7094245e0037ad8db581 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=AB?= Date: Thu, 27 Aug 2026 09:02:09 +0200 Subject: [PATCH] feat(auth): add PIN session persistence, vault sync, and pre-fill login credentials --- main.py | 106 +++++++++++++++++++---- package.json | 2 +- plugin.json | 2 +- src/api/backend.ts | 13 +++ src/components/LoginView.tsx | 8 +- src/components/PinSetupView.tsx | 12 ++- src/components/PinUnlockView.tsx | 2 +- src/components/VaultBrowser.tsx | 142 +++++++++++++++++-------------- src/hooks/useVault.ts | 42 +++++++++ src/index.tsx | 55 ++++++------ 10 files changed, 273 insertions(+), 111 deletions(-) diff --git a/main.py b/main.py index 5e8ef6d..dc20ead 100644 --- a/main.py +++ b/main.py @@ -190,6 +190,25 @@ class Plugin: # ===== Vault Operations ===== + async def sync_vault(self) -> dict: + """Sync vault from server and re-decrypt.""" + try: + if not self.client or not self._session_active: + return {"success": False, "error": "No active session"} + + if not self._enc_key or not self._mac_key: + return {"success": False, "error": "Encryption keys not available"} + + 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 + ) + return {"success": True, "vault_size": len(self._decrypted_vault.get("ciphers", []))} + + except Exception as e: + return {"success": False, "error": str(e)} + async def get_vault_items(self) -> dict: """Get decrypted vault items.""" if not self._decrypted_vault: @@ -295,9 +314,9 @@ class Plugin: pin, pin_salt, kdf_iterations=200000 ) - # Encrypt user key with PIN key + # Encrypt user key with PIN key (store both enc_key + mac_key) envelope, iv = self.pin_crypto.encrypt_user_key_for_pin( - self._enc_key, pin_key + self._enc_key + self._mac_key, pin_key ) # Save PIN settings @@ -306,6 +325,21 @@ class Plugin: settings["pin_kdf_iterations"] = 200000 settings["pin_envelope"] = envelope.hex() settings["pin_envelope_iv"] = iv.hex() + + # Persist tokens encrypted with PIN for session restore across restarts + if self.client and self.client.access_token: + import json as _json + token_data = _json.dumps({ + "access_token": self.client.access_token, + "refresh_token": self.client.refresh_token or "", + "user_id": self.client.user_id or "", + }).encode("utf-8") + token_envelope, token_iv = self.pin_crypto.encrypt_user_key_for_pin( + token_data, pin_key + ) + settings["pin_token_envelope"] = token_envelope.hex() + settings["pin_token_envelope_iv"] = token_iv.hex() + self._save_settings(settings) self._pin_attempts = 0 @@ -345,23 +379,48 @@ class Plugin: 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 + # Old format: only enc_key was stored, mac_key is wrong. + # Clear stale PIN and force re-setup. + 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) + settings.pop("pin_token_envelope", None) + settings.pop("pin_token_envelope_iv", None) + self._save_settings(settings) + return {"success": False, "error": "PIN was set with an older version. Please log in and set up PIN again."} 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", []))} + # Restore session from persisted tokens if no active client (restart scenario) + if not self.client: + token_envelope_hex = settings.get("pin_token_envelope", "") + if token_envelope_hex: + import json as _json + token_envelope = bytes.fromhex(token_envelope_hex) + token_data_bytes = self.pin_crypto.decrypt_user_key_with_pin( + token_envelope, pin_key + ) + token_data = _json.loads(token_data_bytes.decode("utf-8")) - return {"success": False, "error": "No active session"} + server_url = settings.get("server_url", "https://api.bitwarden.com") + self.client = self.client_class(server_url) + self.client.access_token = token_data.get("access_token", "") + self.client.refresh_token = token_data.get("refresh_token", "") + self.client.user_id = token_data.get("user_id", "") + else: + return {"success": False, "error": "No active session and no persisted tokens"} + + # Re-sync and decrypt vault + 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", []))} except Exception as e: self._pin_attempts += 1 @@ -380,6 +439,8 @@ class Plugin: settings.pop("pin_kdf_iterations", None) settings.pop("pin_envelope", None) settings.pop("pin_envelope_iv", None) + settings.pop("pin_token_envelope", None) + settings.pop("pin_token_envelope_iv", None) self._save_settings(settings) return {"success": True} @@ -388,6 +449,16 @@ class Plugin: settings = self._load_settings() return {"enabled": settings.get("pin_enabled", False)} + async def get_saved_credentials(self) -> dict: + """Get saved server URL and email for pre-filling login form.""" + settings = self._load_settings() + return { + "success": True, + "server_url": settings.get("server_url", "https://api.bitwarden.com"), + "email": settings.get("email", ""), + "pin_enabled": settings.get("pin_enabled", False), + } + # ===== Vault Lock ===== async def lock_vault(self) -> dict: @@ -407,6 +478,11 @@ class Plugin: await self.lock_vault() self._master_key = None self.client = None + # Clear persisted tokens from settings + settings = self._load_settings() + settings.pop("pin_token_envelope", None) + settings.pop("pin_token_envelope_iv", None) + self._save_settings(settings) return {"success": True} # ===== Settings ===== diff --git a/package.json b/package.json index dac6537..e33d0a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "decky-vaultwarden", - "version": "1.0.0", + "version": "1.1.0", "description": "Bitwarden/Vaultwarden password manager plugin for Decky Loader", "type": "module", "scripts": { diff --git a/plugin.json b/plugin.json index 54e66c8..92f85ef 100644 --- a/plugin.json +++ b/plugin.json @@ -6,7 +6,7 @@ "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", + "version": "1.1.0", "tags": ["security", "passwords", "bitwarden", "vaultwarden"], "pic_value": "https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/login-logo.png" } diff --git a/src/api/backend.ts b/src/api/backend.ts index 1f458a5..58c060d 100644 --- a/src/api/backend.ts +++ b/src/api/backend.ts @@ -124,6 +124,10 @@ class VaultApi { return this.call("get_vault_items"); } + async syncVault(): Promise<{ success: boolean; vault_size?: number; error?: string }> { + return this.call("sync_vault"); + } + async searchVault(query: string): Promise<{ success: boolean; results: VaultItem[] }> { return this.call("search_vault", query); } @@ -188,6 +192,15 @@ class VaultApi { async updateSettings(settings: any): Promise<{ success: boolean }> { return this.call("update_settings", settings); } + + async getSavedCredentials(): Promise<{ + success: boolean; + server_url: string; + email: string; + pin_enabled: boolean; + }> { + return this.call("get_saved_credentials"); + } } export const vaultApi = new VaultApi(); diff --git a/src/components/LoginView.tsx b/src/components/LoginView.tsx index c3bad3d..afb356f 100644 --- a/src/components/LoginView.tsx +++ b/src/components/LoginView.tsx @@ -24,6 +24,8 @@ interface LoginViewProps { error: string | null; twoFactorRequired?: boolean; needsMasterPassword?: boolean; + defaultServerUrl?: string; + defaultEmail?: string; } export function LoginView({ @@ -33,10 +35,12 @@ export function LoginView({ error, twoFactorRequired, needsMasterPassword, + defaultServerUrl, + defaultEmail, }: LoginViewProps) { const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password"); - const [serverUrl, setServerUrl] = useState("https://api.bitwarden.com"); - const [email, setEmail] = useState(""); + const [serverUrl, setServerUrl] = useState(defaultServerUrl || "https://api.bitwarden.com"); + const [email, setEmail] = useState(defaultEmail || ""); const [password, setPassword] = useState(""); const [twoFactorCode, setTwoFactorCode] = useState(""); const [clientId, setClientId] = useState(""); diff --git a/src/components/PinSetupView.tsx b/src/components/PinSetupView.tsx index d6a17f4..6a0971d 100644 --- a/src/components/PinSetupView.tsx +++ b/src/components/PinSetupView.tsx @@ -3,11 +3,12 @@ import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui"; interface PinSetupViewProps { onSetupPin: (pin: string) => Promise; + onBack: () => void; loading: boolean; error: string | null; } -export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) { +export function PinSetupView({ onSetupPin, onBack, loading, error }: PinSetupViewProps) { const [pin, setPin] = useState(""); const [confirmPin, setConfirmPin] = useState(""); const [localError, setLocalError] = useState(null); @@ -29,6 +30,7 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) if (success) { setPin(""); setConfirmPin(""); + onBack(); } }; @@ -59,6 +61,14 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) {loading ? : "Set Up PIN"} + + Cancel + + {(localError || error) && (
{localError || error} diff --git a/src/components/PinUnlockView.tsx b/src/components/PinUnlockView.tsx index 06ee10e..7fa7e7f 100644 --- a/src/components/PinUnlockView.tsx +++ b/src/components/PinUnlockView.tsx @@ -60,7 +60,7 @@ export function PinUnlockView({ - Login with Master Password + Login as someone else {error && ( diff --git a/src/components/VaultBrowser.tsx b/src/components/VaultBrowser.tsx index 097eadc..f541b0d 100644 --- a/src/components/VaultBrowser.tsx +++ b/src/components/VaultBrowser.tsx @@ -19,6 +19,7 @@ interface VaultBrowserProps { onCopyTotp: (cipherId: string) => Promise; onLock: () => void; onSetupPin: () => void; + onSync: () => void; } export function VaultBrowser({ @@ -32,6 +33,7 @@ export function VaultBrowser({ onCopyTotp, onLock, onSetupPin, + onSync, }: VaultBrowserProps) { const [selectedFolder, setSelectedFolder] = useState(null); const [localSearch, setLocalSearch] = useState(searchQuery); @@ -63,81 +65,89 @@ export function VaultBrowser({ return ( - {/* Search */} - ) => handleSearchChange(e.target.value)} - /> - - {/* Folder tabs */} - {!searchQuery && ( -
- !c.error).length} - selected={selectedFolder === null} - onClick={() => setSelectedFolder(null)} - /> - {vaultData.folders.map((folder) => ( - c.folderId === folder.id && !c.error - ).length} - selected={selectedFolder === folder.id} - onClick={() => setSelectedFolder(folder.id)} - /> - ))} -
- )} - - {/* Items */} - {displayedItems.length === 0 ? ( + {/* Search */} + ) => handleSearchChange(e.target.value)} + /> + + {/* Folder tabs */} + {!searchQuery && (
- {searchQuery ? "No items found" : "No items in this folder"} -
- ) : ( - displayedItems.map((item) => ( - !c.error).length} + selected={selectedFolder === null} + onClick={() => setSelectedFolder(null)} /> - )) + {vaultData.folders.map((folder) => ( + c.folderId === folder.id && !c.error + ).length} + selected={selectedFolder === folder.id} + onClick={() => setSelectedFolder(folder.id)} + /> + ))} +
)} - - {/* Actions */} - - Lock Vault - - - Set Up PIN - + {/* Items */} + + {displayedItems.length === 0 ? ( +
+ {searchQuery ? "No items found" : "No items in this folder"} +
+ ) : ( + displayedItems.map((item) => ( + + )) + )} +
+ + {/* Actions */} + + Sync Vault + + + Lock Vault + + + Set Up PIN + + ); } diff --git a/src/hooks/useVault.ts b/src/hooks/useVault.ts index e90cfd0..158c3d8 100644 --- a/src/hooks/useVault.ts +++ b/src/hooks/useVault.ts @@ -6,6 +6,7 @@ export type VaultState = | "two_factor_required" | "needs_master_password" | "pin_only" + | "pin_setup" | "loading" | "unlocked"; @@ -22,6 +23,10 @@ export function useVault() { cipherId: string; expiresAt: number; } | null>(null); + const [savedCredentials, setSavedCredentials] = useState<{ + serverUrl: string; + email: string; + } | null>(null); // Check initial status useEffect(() => { @@ -47,6 +52,19 @@ export function useVault() { } else { setState("logged_out"); } + + // Fetch saved credentials for pre-filling login form + try { + const creds = await vaultApi.getSavedCredentials(); + if (creds.success && (creds.email || creds.server_url)) { + setSavedCredentials({ + serverUrl: creds.server_url, + email: creds.email, + }); + } + } catch { + // Ignore errors fetching saved credentials + } } catch (e) { setState("logged_out"); } @@ -336,6 +354,27 @@ export function useVault() { } }, []); + const enterPinSetup = useCallback(() => { + setState("pin_setup"); + }, []); + + const syncVault = useCallback(async () => { + setState("loading"); + setError(null); + try { + const syncResult = await vaultApi.syncVault(); + if (syncResult.success) { + await loadVault(); + } else { + setError(syncResult.error || "Sync failed"); + setState("unlocked"); + } + } catch (e: any) { + setError(e.message || "Sync failed"); + setState("unlocked"); + } + }, []); + return { state, vaultData, @@ -345,6 +384,7 @@ export function useVault() { searchQuery, searchResults, copyStatus, + savedCredentials, setSelectedFolder, loginPassword, loginApiKey, @@ -358,6 +398,8 @@ export function useVault() { logout, setupPin, removePin, + enterPinSetup, + syncVault, clearError: () => setError(null), }; } diff --git a/src/index.tsx b/src/index.tsx index 83fac90..b0afc65 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,11 +6,10 @@ 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"; +import { PinSetupView } from "./components/PinSetupView"; function VaultwardenPlugin() { const vault = useVault(); - const [showPinSetup, setShowPinSetup] = useState(false); // Handle keyboard input for PIN useEffect(() => { @@ -44,6 +43,8 @@ function VaultwardenPlugin() { onApiKeyLogin={vault.loginApiKey} loading={false} error={vault.error} + defaultServerUrl={vault.savedCredentials?.serverUrl} + defaultEmail={vault.savedCredentials?.email} /> ); } @@ -56,6 +57,8 @@ function VaultwardenPlugin() { loading={false} error={vault.error} twoFactorRequired + defaultServerUrl={vault.savedCredentials?.serverUrl} + defaultEmail={vault.savedCredentials?.email} /> ); } @@ -81,30 +84,32 @@ function VaultwardenPlugin() { ); } + if (vault.state === "pin_setup") { + return ( + vault.lockVault()} + loading={false} + error={vault.error} + /> + ); + } + if (vault.state === "unlocked" && vault.vaultData) { return ( - <> - setShowPinSetup(true)} - /> - - setShowPinSetup(false)} - onSetupPin={vault.setupPin} - loading={false} - error={vault.error} - /> - + ); } @@ -114,6 +119,8 @@ function VaultwardenPlugin() { onApiKeyLogin={vault.loginApiKey} loading={false} error={vault.error} + defaultServerUrl={vault.savedCredentials?.serverUrl} + defaultEmail={vault.savedCredentials?.email} /> ); }