feat(auth): add PIN session persistence, vault sync, and pre-fill login credentials

This commit is contained in:
2026-08-27 09:14:26 +02:00
parent 569777ce8a
commit 3bb5c5b1de
10 changed files with 273 additions and 111 deletions
+91 -15
View File
@@ -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 =====
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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"
}
+13
View File
@@ -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();
+6 -2
View File
@@ -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("");
+11 -1
View File
@@ -3,11 +3,12 @@ import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinSetupViewProps {
onSetupPin: (pin: string) => Promise<boolean>;
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<string | null>(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 ? <Spinner width={16} height={16} /> : "Set Up PIN"}
</ButtonItem>
<ButtonItem
layout="below"
onClick={onBack}
disabled={loading}
>
Cancel
</ButtonItem>
{(localError || error) && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{localError || error}
+1 -1
View File
@@ -60,7 +60,7 @@ export function PinUnlockView({
</ButtonItem>
<ButtonItem layout="below" onClick={onLogout} disabled={loading}>
Login with Master Password
Login as someone else
</ButtonItem>
{error && (
+76 -66
View File
@@ -19,6 +19,7 @@ interface VaultBrowserProps {
onCopyTotp: (cipherId: string) => Promise<boolean>;
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<string | null>(null);
const [localSearch, setLocalSearch] = useState(searchQuery);
@@ -63,81 +65,89 @@ export function VaultBrowser({
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 */}
<Focusable
flow-children="y"
style={{ maxHeight: "400px", overflowY: "auto" }}
onSecondaryButton={onSync}
onSecondaryActionDescription="Sync Vault"
>
{displayedItems.length === 0 ? (
{/* Search */}
<TextField
label="Search"
description={`${displayedItems.length} items`}
value={localSearch}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => handleSearchChange(e.target.value)}
/>
{/* Folder tabs */}
{!searchQuery && (
<div
style={{
textAlign: "center",
padding: "24px",
color: "#888",
display: "flex",
flexWrap: "wrap",
gap: "4px",
marginBottom: "8px",
}}
>
{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}
<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>
)}
</Focusable>
{/* Actions */}
<ButtonItem layout="below" onClick={onLock}>
Lock Vault
</ButtonItem>
<ButtonItem layout="below" onClick={onSetupPin}>
Set Up PIN
</ButtonItem>
{/* Items */}
<Focusable
flow-children="y"
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}
/>
))
)}
</Focusable>
{/* Actions */}
<ButtonItem layout="below" onClick={onSync}>
Sync Vault
</ButtonItem>
<ButtonItem layout="below" onClick={onLock}>
Lock Vault
</ButtonItem>
<ButtonItem layout="below" onClick={onSetupPin}>
Set Up PIN
</ButtonItem>
</Focusable>
</PanelSection>
);
}
+42
View File
@@ -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),
};
}
+31 -24
View File
@@ -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 (
<PinSetupView
onSetupPin={vault.setupPin}
onBack={() => vault.lockVault()}
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}
/>
</>
<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={vault.enterPinSetup}
onSync={vault.syncVault}
/>
);
}
@@ -114,6 +119,8 @@ function VaultwardenPlugin() {
onApiKeyLogin={vault.loginApiKey}
loading={false}
error={vault.error}
defaultServerUrl={vault.savedCredentials?.serverUrl}
defaultEmail={vault.savedCredentials?.email}
/>
);
}