1 Commits
10 changed files with 273 additions and 111 deletions
+91 -15
View File
@@ -190,6 +190,25 @@ class Plugin:
# ===== Vault Operations ===== # ===== 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: async def get_vault_items(self) -> dict:
"""Get decrypted vault items.""" """Get decrypted vault items."""
if not self._decrypted_vault: if not self._decrypted_vault:
@@ -295,9 +314,9 @@ class Plugin:
pin, pin_salt, kdf_iterations=200000 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( 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 # Save PIN settings
@@ -306,6 +325,21 @@ class Plugin:
settings["pin_kdf_iterations"] = 200000 settings["pin_kdf_iterations"] = 200000
settings["pin_envelope"] = envelope.hex() settings["pin_envelope"] = envelope.hex()
settings["pin_envelope_iv"] = iv.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._save_settings(settings)
self._pin_attempts = 0 self._pin_attempts = 0
@@ -345,23 +379,48 @@ class Plugin:
self._enc_key = user_key[:32] self._enc_key = user_key[:32]
self._mac_key = user_key[32:] self._mac_key = user_key[32:]
elif len(user_key) == 32: elif len(user_key) == 32:
self._enc_key = user_key # Old format: only enc_key was stored, mac_key is wrong.
self._mac_key = user_key # 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: else:
raise ValueError("Invalid decrypted user key length") raise ValueError("Invalid decrypted user key length")
# Re-sync and decrypt vault # Restore session from persisted tokens if no active client (restart scenario)
if self.client: if not self.client:
sync_data = await self.client.sync_vault() token_envelope_hex = settings.get("pin_token_envelope", "")
self._vault_data = sync_data if token_envelope_hex:
self._decrypted_vault = self.client.decrypt_vault( import json as _json
sync_data, self._enc_key, self._mac_key token_envelope = bytes.fromhex(token_envelope_hex)
) token_data_bytes = self.pin_crypto.decrypt_user_key_with_pin(
self._session_active = True token_envelope, pin_key
self._pin_attempts = 0 )
return {"success": True, "vault_size": len(self._decrypted_vault.get("ciphers", []))} 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: except Exception as e:
self._pin_attempts += 1 self._pin_attempts += 1
@@ -380,6 +439,8 @@ class Plugin:
settings.pop("pin_kdf_iterations", None) settings.pop("pin_kdf_iterations", None)
settings.pop("pin_envelope", None) settings.pop("pin_envelope", None)
settings.pop("pin_envelope_iv", None) settings.pop("pin_envelope_iv", None)
settings.pop("pin_token_envelope", None)
settings.pop("pin_token_envelope_iv", None)
self._save_settings(settings) self._save_settings(settings)
return {"success": True} return {"success": True}
@@ -388,6 +449,16 @@ class Plugin:
settings = self._load_settings() settings = self._load_settings()
return {"enabled": settings.get("pin_enabled", False)} 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 ===== # ===== Vault Lock =====
async def lock_vault(self) -> dict: async def lock_vault(self) -> dict:
@@ -407,6 +478,11 @@ class Plugin:
await self.lock_vault() await self.lock_vault()
self._master_key = None self._master_key = None
self.client = 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} return {"success": True}
# ===== Settings ===== # ===== Settings =====
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "decky-vaultwarden", "name": "decky-vaultwarden",
"version": "1.0.0", "version": "1.1.0",
"description": "Bitwarden/Vaultwarden password manager plugin for Decky Loader", "description": "Bitwarden/Vaultwarden password manager plugin for Decky Loader",
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -6,7 +6,7 @@
"content": { "content": {
"name": "Vaultwarden - Password Manager", "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.", "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"], "tags": ["security", "passwords", "bitwarden", "vaultwarden"],
"pic_value": "https://raw.githubusercontent.com/bitwarden/brand/master/screenshots/login-logo.png" "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"); 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[] }> { async searchVault(query: string): Promise<{ success: boolean; results: VaultItem[] }> {
return this.call("search_vault", query); return this.call("search_vault", query);
} }
@@ -188,6 +192,15 @@ class VaultApi {
async updateSettings(settings: any): Promise<{ success: boolean }> { async updateSettings(settings: any): Promise<{ success: boolean }> {
return this.call("update_settings", settings); 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(); export const vaultApi = new VaultApi();
+6 -2
View File
@@ -24,6 +24,8 @@ interface LoginViewProps {
error: string | null; error: string | null;
twoFactorRequired?: boolean; twoFactorRequired?: boolean;
needsMasterPassword?: boolean; needsMasterPassword?: boolean;
defaultServerUrl?: string;
defaultEmail?: string;
} }
export function LoginView({ export function LoginView({
@@ -33,10 +35,12 @@ export function LoginView({
error, error,
twoFactorRequired, twoFactorRequired,
needsMasterPassword, needsMasterPassword,
defaultServerUrl,
defaultEmail,
}: LoginViewProps) { }: LoginViewProps) {
const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password"); const [authMethod, setAuthMethod] = useState<"password" | "apikey">("password");
const [serverUrl, setServerUrl] = useState("https://api.bitwarden.com"); const [serverUrl, setServerUrl] = useState(defaultServerUrl || "https://api.bitwarden.com");
const [email, setEmail] = useState(""); const [email, setEmail] = useState(defaultEmail || "");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [twoFactorCode, setTwoFactorCode] = useState(""); const [twoFactorCode, setTwoFactorCode] = useState("");
const [clientId, setClientId] = useState(""); const [clientId, setClientId] = useState("");
+11 -1
View File
@@ -3,11 +3,12 @@ import { PanelSection, TextField, ButtonItem, Spinner } from "@decky/ui";
interface PinSetupViewProps { interface PinSetupViewProps {
onSetupPin: (pin: string) => Promise<boolean>; onSetupPin: (pin: string) => Promise<boolean>;
onBack: () => void;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
} }
export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps) { export function PinSetupView({ onSetupPin, onBack, loading, error }: PinSetupViewProps) {
const [pin, setPin] = useState(""); const [pin, setPin] = useState("");
const [confirmPin, setConfirmPin] = useState(""); const [confirmPin, setConfirmPin] = useState("");
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
@@ -29,6 +30,7 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps)
if (success) { if (success) {
setPin(""); setPin("");
setConfirmPin(""); setConfirmPin("");
onBack();
} }
}; };
@@ -59,6 +61,14 @@ export function PinSetupView({ onSetupPin, loading, error }: PinSetupViewProps)
{loading ? <Spinner width={16} height={16} /> : "Set Up PIN"} {loading ? <Spinner width={16} height={16} /> : "Set Up PIN"}
</ButtonItem> </ButtonItem>
<ButtonItem
layout="below"
onClick={onBack}
disabled={loading}
>
Cancel
</ButtonItem>
{(localError || error) && ( {(localError || error) && (
<div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}> <div style={{ color: "#ff4444", padding: "8px", fontSize: "12px" }}>
{localError || error} {localError || error}
+1 -1
View File
@@ -60,7 +60,7 @@ export function PinUnlockView({
</ButtonItem> </ButtonItem>
<ButtonItem layout="below" onClick={onLogout} disabled={loading}> <ButtonItem layout="below" onClick={onLogout} disabled={loading}>
Login with Master Password Login as someone else
</ButtonItem> </ButtonItem>
{error && ( {error && (
+76 -66
View File
@@ -19,6 +19,7 @@ interface VaultBrowserProps {
onCopyTotp: (cipherId: string) => Promise<boolean>; onCopyTotp: (cipherId: string) => Promise<boolean>;
onLock: () => void; onLock: () => void;
onSetupPin: () => void; onSetupPin: () => void;
onSync: () => void;
} }
export function VaultBrowser({ export function VaultBrowser({
@@ -32,6 +33,7 @@ export function VaultBrowser({
onCopyTotp, onCopyTotp,
onLock, onLock,
onSetupPin, onSetupPin,
onSync,
}: VaultBrowserProps) { }: VaultBrowserProps) {
const [selectedFolder, setSelectedFolder] = useState<string | null>(null); const [selectedFolder, setSelectedFolder] = useState<string | null>(null);
const [localSearch, setLocalSearch] = useState(searchQuery); const [localSearch, setLocalSearch] = useState(searchQuery);
@@ -63,81 +65,89 @@ export function VaultBrowser({
return ( return (
<PanelSection title="Vault"> <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 <Focusable
flow-children="y" onSecondaryButton={onSync}
style={{ maxHeight: "400px", overflowY: "auto" }} 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 <div
style={{ style={{
textAlign: "center", display: "flex",
padding: "24px", flexWrap: "wrap",
color: "#888", gap: "4px",
marginBottom: "8px",
}} }}
> >
{searchQuery ? "No items found" : "No items in this folder"} <FolderTab
</div> name="All"
) : ( count={vaultData.ciphers.filter((c) => !c.error).length}
displayedItems.map((item) => ( selected={selectedFolder === null}
<ItemCard onClick={() => setSelectedFolder(null)}
key={item.id}
item={item}
folderMap={vaultData.folder_map}
copyStatus={copyStatus}
onCopyPassword={onCopyPassword}
onCopyUsername={onCopyUsername}
onCopyTotp={onCopyTotp}
/> />
)) {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 */} {/* Items */}
<ButtonItem layout="below" onClick={onLock}> <Focusable
Lock Vault flow-children="y"
</ButtonItem> style={{ maxHeight: "400px", overflowY: "auto" }}
<ButtonItem layout="below" onClick={onSetupPin}> >
Set Up PIN {displayedItems.length === 0 ? (
</ButtonItem> <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> </PanelSection>
); );
} }
+42
View File
@@ -6,6 +6,7 @@ export type VaultState =
| "two_factor_required" | "two_factor_required"
| "needs_master_password" | "needs_master_password"
| "pin_only" | "pin_only"
| "pin_setup"
| "loading" | "loading"
| "unlocked"; | "unlocked";
@@ -22,6 +23,10 @@ export function useVault() {
cipherId: string; cipherId: string;
expiresAt: number; expiresAt: number;
} | null>(null); } | null>(null);
const [savedCredentials, setSavedCredentials] = useState<{
serverUrl: string;
email: string;
} | null>(null);
// Check initial status // Check initial status
useEffect(() => { useEffect(() => {
@@ -47,6 +52,19 @@ export function useVault() {
} else { } else {
setState("logged_out"); 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) { } catch (e) {
setState("logged_out"); 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 { return {
state, state,
vaultData, vaultData,
@@ -345,6 +384,7 @@ export function useVault() {
searchQuery, searchQuery,
searchResults, searchResults,
copyStatus, copyStatus,
savedCredentials,
setSelectedFolder, setSelectedFolder,
loginPassword, loginPassword,
loginApiKey, loginApiKey,
@@ -358,6 +398,8 @@ export function useVault() {
logout, logout,
setupPin, setupPin,
removePin, removePin,
enterPinSetup,
syncVault,
clearError: () => setError(null), clearError: () => setError(null),
}; };
} }
+31 -24
View File
@@ -6,11 +6,10 @@ import { useVault } from "./hooks/useVault";
import { LoginView } from "./components/LoginView"; import { LoginView } from "./components/LoginView";
import { PinUnlockView } from "./components/PinUnlockView"; import { PinUnlockView } from "./components/PinUnlockView";
import { VaultBrowser } from "./components/VaultBrowser"; import { VaultBrowser } from "./components/VaultBrowser";
import { PinSetupModal } from "./components/PinSetupModal"; import { PinSetupView } from "./components/PinSetupView";
function VaultwardenPlugin() { function VaultwardenPlugin() {
const vault = useVault(); const vault = useVault();
const [showPinSetup, setShowPinSetup] = useState(false);
// Handle keyboard input for PIN // Handle keyboard input for PIN
useEffect(() => { useEffect(() => {
@@ -44,6 +43,8 @@ function VaultwardenPlugin() {
onApiKeyLogin={vault.loginApiKey} onApiKeyLogin={vault.loginApiKey}
loading={false} loading={false}
error={vault.error} error={vault.error}
defaultServerUrl={vault.savedCredentials?.serverUrl}
defaultEmail={vault.savedCredentials?.email}
/> />
); );
} }
@@ -56,6 +57,8 @@ function VaultwardenPlugin() {
loading={false} loading={false}
error={vault.error} error={vault.error}
twoFactorRequired 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) { if (vault.state === "unlocked" && vault.vaultData) {
return ( return (
<> <VaultBrowser
<VaultBrowser vaultData={vault.vaultData}
vaultData={vault.vaultData} searchQuery={vault.searchQuery}
searchQuery={vault.searchQuery} searchResults={vault.searchResults}
searchResults={vault.searchResults} copyStatus={vault.copyStatus}
copyStatus={vault.copyStatus} onSearch={vault.search}
onSearch={vault.search} onCopyPassword={vault.copyPassword}
onCopyPassword={vault.copyPassword} onCopyUsername={vault.copyUsername}
onCopyUsername={vault.copyUsername} onCopyTotp={vault.copyTotp}
onCopyTotp={vault.copyTotp} onLock={vault.lockVault}
onLock={vault.lockVault} onSetupPin={vault.enterPinSetup}
onSetupPin={() => setShowPinSetup(true)} onSync={vault.syncVault}
/> />
<PinSetupModal
isOpen={showPinSetup}
onClose={() => setShowPinSetup(false)}
onSetupPin={vault.setupPin}
loading={false}
error={vault.error}
/>
</>
); );
} }
@@ -114,6 +119,8 @@ function VaultwardenPlugin() {
onApiKeyLogin={vault.loginApiKey} onApiKeyLogin={vault.loginApiKey}
loading={false} loading={false}
error={vault.error} error={vault.error}
defaultServerUrl={vault.savedCredentials?.serverUrl}
defaultEmail={vault.savedCredentials?.email}
/> />
); );
} }