615 lines
21 KiB
Python
615 lines
21 KiB
Python
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from fabric.utils import Gdk, GLib, idle_add, remove_handler
|
|
from fabric.widgets.box import Box
|
|
from fabric.widgets.button import Button
|
|
from fabric.widgets.label import Label
|
|
|
|
from config.data import plugins
|
|
from modules.launcher.base import LauncherPlugin
|
|
from services import PasswordManager
|
|
from utils.functions import copy_text, trigger_paste_shortcut
|
|
|
|
|
|
class PasswordManagerPlugin(LauncherPlugin):
|
|
@property
|
|
def name(self) -> str:
|
|
return "passwords"
|
|
|
|
@property
|
|
def icon(self) -> str:
|
|
return "dialog-password-symbolic"
|
|
|
|
@property
|
|
def keywords(self) -> list[str]:
|
|
return ["pass"]
|
|
|
|
def __init__(self, handler):
|
|
super().__init__(handler)
|
|
self.passwords_dir = Path(plugins) / "passwords"
|
|
self.passwords_dir.mkdir(parents=True, exist_ok=True)
|
|
self.passwords_file_path = self.passwords_dir / "passwords.json"
|
|
self.key_file_path = self.passwords_dir / "key.key"
|
|
self.salt_file_path = self.passwords_dir / "salt.key"
|
|
|
|
self._query_handler: int = 0
|
|
self.current_slots: list = []
|
|
self.passwords: dict = {}
|
|
self.password_manager = None
|
|
self.master_password_set = False
|
|
self._awaiting_master_password: bool = False
|
|
|
|
def on_search(self, text: str) -> None:
|
|
self.query_passwords(text)
|
|
|
|
def stop(self):
|
|
if self._query_handler:
|
|
remove_handler(self._query_handler)
|
|
self._query_handler = 0
|
|
self.current_slots.clear()
|
|
|
|
@property
|
|
def is_password_mode(self) -> bool:
|
|
return self._awaiting_master_password
|
|
|
|
def _is_unlocked(self) -> bool:
|
|
return self.password_manager is not None and self.master_password_set
|
|
|
|
def is_waiting_for_password(self) -> bool:
|
|
return self._awaiting_master_password
|
|
|
|
def _get_password_manager(self, master_password: str = None):
|
|
if self.password_manager is None and master_password:
|
|
self.password_manager = PasswordManager(master_password)
|
|
self.password_manager.passwords_file = self.passwords_file_path
|
|
self.password_manager.key_file = self.key_file_path
|
|
self.password_manager.salt_file = self.salt_file_path
|
|
self.password_manager._initialize_password_file()
|
|
self.password_manager._setup_encryption()
|
|
self.master_password_set = True
|
|
return self.password_manager
|
|
|
|
def _check_master_password_exists(self) -> bool:
|
|
return self.key_file_path.exists()
|
|
|
|
def _initialize_password_manager(self, password: str) -> bool:
|
|
try:
|
|
self.password_manager = PasswordManager(password)
|
|
self.password_manager.passwords_file = self.passwords_file_path
|
|
self.password_manager.key_file = self.key_file_path
|
|
self.password_manager.salt_file = self.salt_file_path
|
|
self.password_manager._initialize_password_file()
|
|
self.password_manager._setup_encryption()
|
|
self.master_password_set = True
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def _verify_master_password(self, password: str) -> bool:
|
|
try:
|
|
temp_pm = PasswordManager(password)
|
|
temp_pm.passwords_file = self.passwords_file_path
|
|
temp_pm.key_file = self.key_file_path
|
|
temp_pm.salt_file = self.salt_file_path
|
|
temp_pm._initialize_password_file()
|
|
temp_pm._setup_encryption()
|
|
|
|
return temp_pm.verify_master_password(password)
|
|
except Exception:
|
|
return False
|
|
|
|
def query_passwords(self, text: str) -> None:
|
|
self.handler.start("passwords")
|
|
self.current_slots.clear()
|
|
text_stripped = text.strip()
|
|
|
|
commands = {
|
|
"lock": self._handle_lock_command,
|
|
"help": lambda t: self._show_command_help(),
|
|
"add": self._handle_add_command,
|
|
"remove": self._handle_remove_command,
|
|
"update": self._handle_update_command,
|
|
}
|
|
|
|
for prefix, handler in commands.items():
|
|
if text_stripped.startswith(prefix):
|
|
if (
|
|
prefix in ("add", "remove", "update")
|
|
and not self._is_unlocked()
|
|
and self._check_master_password_exists()
|
|
):
|
|
self._prompt_master_password()
|
|
return
|
|
if prefix in ("add", "remove", "update") and not self._is_unlocked():
|
|
self._show_master_password_required()
|
|
return
|
|
handler(text)
|
|
return
|
|
|
|
if not self._is_unlocked():
|
|
if self._check_master_password_exists():
|
|
self._prompt_master_password()
|
|
else:
|
|
self._prompt_master_password_for_setup()
|
|
return
|
|
self._load_and_display_passwords(text)
|
|
|
|
def _load_and_display_passwords(self, text: str) -> None:
|
|
pm = self._get_password_manager()
|
|
if not pm:
|
|
self._show_master_password_required()
|
|
return
|
|
|
|
result = pm.list_passwords(decrypt_passwords=False)
|
|
if not result["success"]:
|
|
self._show_error_message(f"Failed to load passwords: {result['error']}")
|
|
return
|
|
|
|
self.passwords = result["data"]
|
|
# Remove the master password hash and salt from display
|
|
self.passwords.pop("_master_password_hash", None)
|
|
self.passwords.pop("_master_password_salt", None)
|
|
|
|
if not self.passwords:
|
|
self._show_no_passwords()
|
|
return
|
|
|
|
filtered_passwords = self._filter_passwords(text)
|
|
if not filtered_passwords:
|
|
self._show_no_results()
|
|
return
|
|
|
|
self._query_handler = idle_add(
|
|
self._bake_next_password_slot, iter(filtered_passwords), pin=True
|
|
)
|
|
|
|
def _filter_passwords(self, text: str) -> Iterator[tuple[str, dict]]:
|
|
if not text.strip():
|
|
return iter(self.passwords.items())
|
|
|
|
text_lower = text.lower()
|
|
return iter(
|
|
(name, data)
|
|
for name, data in self.passwords.items()
|
|
if (
|
|
text_lower in name.lower()
|
|
or text_lower in data.get("username", "").lower()
|
|
)
|
|
)
|
|
|
|
def _show_no_passwords(self) -> None:
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-no-passwords",
|
|
child=Box(
|
|
orientation="h",
|
|
spacing=12,
|
|
children=[
|
|
Label(
|
|
label="No passwords found",
|
|
style_classes="password-no-passwords-text",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
tooltip_text="Use 'add name username password' to add passwords",
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _show_no_results(self) -> None:
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-no-results",
|
|
child=Box(
|
|
orientation="h",
|
|
spacing=12,
|
|
children=[
|
|
Label(
|
|
label="No passwords match your search",
|
|
style_classes="password-no-results-text",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _bake_next_password_slot(self, iterator: Iterator[tuple[str, dict]]) -> bool:
|
|
try:
|
|
password_name, password_data = next(iterator)
|
|
except StopIteration:
|
|
idle_add(self.handler.done)
|
|
return False
|
|
|
|
self._create_password_slot(password_name, password_data)
|
|
return True
|
|
|
|
def _create_password_slot(self, password_name: str, password_data: dict) -> None:
|
|
username = password_data.get("username", "")
|
|
name_label = Label(
|
|
label=password_name,
|
|
style_classes="password-name",
|
|
h_align="start",
|
|
max_chars_width=25,
|
|
ellipsization="end",
|
|
)
|
|
|
|
username_label = Label(
|
|
label=username if username else "No username",
|
|
style_classes="password-username",
|
|
h_align="start",
|
|
max_chars_width=30,
|
|
ellipsization="end",
|
|
)
|
|
|
|
children = [name_label, username_label]
|
|
|
|
slot_widget = Button(
|
|
style_classes="app-slot password-slot",
|
|
child=Box(
|
|
orientation="v",
|
|
spacing=4,
|
|
children=children,
|
|
),
|
|
tooltip_text="Click to copy password",
|
|
on_clicked=lambda *_: self._copy_password(password_name),
|
|
)
|
|
|
|
slot_widget.connect(
|
|
"key-press-event",
|
|
lambda widget, event, pwd_name=password_name: self._on_slot_key_press(
|
|
widget, event, pwd_name
|
|
),
|
|
)
|
|
|
|
slot_info = {
|
|
"widget": slot_widget,
|
|
"name_label": name_label,
|
|
"username_label": username_label,
|
|
"password_name": password_name,
|
|
"password_data": password_data,
|
|
}
|
|
self.current_slots.append(slot_info)
|
|
self.handler.slot_ready(slot_widget, "passwords")
|
|
|
|
def _copy_password(self, password_name: str) -> None:
|
|
pm = self._get_password_manager()
|
|
if not pm:
|
|
self._show_master_password_required()
|
|
return
|
|
|
|
result = pm.get_password(password_name, decrypt=True)
|
|
if not result["success"]:
|
|
self._show_error_message(f"Failed to get password: {result['error']}")
|
|
return
|
|
|
|
password = result["data"]["password"]
|
|
copy_text(password)
|
|
self.handler.launched()
|
|
GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
|
|
|
|
def _on_slot_key_press(self, widget, event, password_name: str) -> bool:
|
|
if not event:
|
|
return False
|
|
return (
|
|
(event.keyval == Gdk.KEY_Return or event.keyval == Gdk.KEY_KP_Enter)
|
|
and event.state & Gdk.ModifierType.SHIFT_MASK
|
|
and (self._handle_remove_command(f"remove {password_name};") or True)
|
|
)
|
|
|
|
def _prompt_master_password(self) -> None:
|
|
self._awaiting_master_password = True
|
|
self.handler.start("passwords")
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-help",
|
|
child=Box(
|
|
orientation="v",
|
|
spacing=8,
|
|
children=[
|
|
Label(
|
|
label="Enter master password",
|
|
style_classes="password-help-text",
|
|
h_align="center",
|
|
),
|
|
Label(
|
|
label="Enter=Submit • Empty=Cancel",
|
|
style_classes="password-help-subtext",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _prompt_master_password_for_setup(self) -> None:
|
|
self._awaiting_master_password = True
|
|
self.handler.start("passwords")
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-help",
|
|
child=Box(
|
|
orientation="v",
|
|
spacing=8,
|
|
children=[
|
|
Label(
|
|
label="Set master password",
|
|
style_classes="password-help-text",
|
|
h_align="center",
|
|
),
|
|
Label(
|
|
label="Enter=Submit • Empty=Cancel",
|
|
style_classes="password-help-subtext",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def submit_password(self, password: str) -> None:
|
|
if not self._awaiting_master_password:
|
|
return
|
|
if not password:
|
|
self.cancel_password()
|
|
return
|
|
|
|
# Check if this is setup (no existing master password) or unlock (existing master password)
|
|
if not self._check_master_password_exists():
|
|
# Setup mode: initialize new master password
|
|
if not self._initialize_password_manager(password):
|
|
self._show_error_message("Failed to initialize password manager!")
|
|
return
|
|
else:
|
|
# Unlock mode: verify existing master password
|
|
if not self._verify_master_password(password):
|
|
self._show_error_message("Invalid master password!")
|
|
return
|
|
if not self._initialize_password_manager(password):
|
|
self._show_error_message("Failed to initialize password manager!")
|
|
return
|
|
|
|
self._awaiting_master_password = False
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
self._refresh_password_list()
|
|
|
|
def cancel_password(self) -> None:
|
|
if not self._awaiting_master_password:
|
|
return
|
|
self._awaiting_master_password = False
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
self._show_master_password_required()
|
|
|
|
def reset_prompt_state(self) -> None:
|
|
self._awaiting_master_password = False
|
|
|
|
def _handle_lock_command(self, text: str) -> None:
|
|
self.password_manager = None
|
|
self.master_password_set = False
|
|
self.current_slots.clear()
|
|
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
self.handler.launched()
|
|
|
|
def _handle_add_command(self, text: str) -> None:
|
|
if not text.strip().endswith(";"):
|
|
self._show_error_message(
|
|
"Add command must end with ';'\n\nUsage: add name username password;"
|
|
)
|
|
return
|
|
|
|
password_text = text.strip()[:-1] # Remove semicolon
|
|
parts = password_text.split(
|
|
None, 3
|
|
) # Split into max 4 parts: ["add", "name", "username", "password with spaces"]
|
|
if len(parts) < 4:
|
|
self._show_command_help("add")
|
|
return
|
|
|
|
name = parts[1]
|
|
username = parts[2]
|
|
password = parts[
|
|
3
|
|
] # This will contain the entire password, even if it has spaces
|
|
|
|
pm = self._get_password_manager()
|
|
if not pm:
|
|
self._show_master_password_required()
|
|
return
|
|
|
|
result = pm.add_password(name, username, password)
|
|
if result["success"]:
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
self._refresh_password_list()
|
|
else:
|
|
self._show_error_message(f"Error adding password: {result['error']}")
|
|
|
|
def _handle_remove_command(self, text: str) -> None:
|
|
if text.strip() == "remove":
|
|
self._load_and_display_passwords("")
|
|
return
|
|
|
|
if not text.strip().endswith(";"):
|
|
self._show_error_message(
|
|
"Remove command must end with ';'\n\nUsage: remove name;"
|
|
)
|
|
return
|
|
|
|
password_text = text.strip()[:-1]
|
|
parts = password_text.split()
|
|
if len(parts) < 2:
|
|
self._show_command_help("remove")
|
|
return
|
|
|
|
password_name = parts[1]
|
|
pm = self._get_password_manager()
|
|
if not pm:
|
|
self._show_master_password_required()
|
|
return
|
|
|
|
result = pm.delete_password(password_name)
|
|
if not result["success"]:
|
|
self._show_error_message(f"Error removing password: {result['error']}")
|
|
return
|
|
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
self._refresh_password_list()
|
|
|
|
def _handle_update_command(self, text: str) -> None:
|
|
if text.strip() == "update":
|
|
self._load_and_display_passwords("")
|
|
return
|
|
|
|
if not text.strip().endswith(";"):
|
|
self._show_error_message(
|
|
"Update command must end with ';'\n\nUsage: update name field new_value;"
|
|
)
|
|
return
|
|
|
|
password_text = text.strip()[:-1]
|
|
parts = password_text.split()
|
|
if len(parts) < 4:
|
|
self._show_command_help("update")
|
|
return
|
|
|
|
password_name = parts[1]
|
|
field = parts[2]
|
|
|
|
if field not in ["username", "password"]:
|
|
self._show_error_message("Invalid field. Use: username or password")
|
|
return
|
|
|
|
new_value = " ".join(parts[3:])
|
|
pm = self._get_password_manager()
|
|
if not pm:
|
|
self._show_master_password_required()
|
|
return
|
|
|
|
result = pm.update_password(password_name, **{field: new_value})
|
|
if not result["success"]:
|
|
self._show_error_message(f"Error updating password: {result['error']}")
|
|
return
|
|
|
|
if hasattr(self.handler, "clear_input"):
|
|
self.handler.clear_input()
|
|
self._refresh_password_list()
|
|
|
|
def _show_master_password_required(self) -> None:
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-master-required",
|
|
child=Box(
|
|
orientation="h",
|
|
spacing=12,
|
|
children=[
|
|
Label(
|
|
label="Master password required!\n\nYou'll be prompted to set a master password on first use, or to unlock if already set.",
|
|
style_classes="password-master-required-text",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
tooltip_text="Set a master password to encrypt your passwords",
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _show_command_help(self, command: str = None) -> None:
|
|
help_texts = {
|
|
"add": "Usage: add name username password;\nExample: add gmail user@gmail.com mypass;\nNote: Must end with semicolon (;)",
|
|
"remove": "Usage: remove name;\nExample: remove gmail;\nNote: Must end with semicolon (;)",
|
|
"update": "Usage: update name field new_value;\nFields: username, password\nExample: update gmail password newpass123;\nNote: Must end with semicolon (;)",
|
|
"lock": "Usage: lock\nExample: lock\n\nThis locks your password vault for security.\nYou'll need to enter the master password in the prompt when requested.",
|
|
}
|
|
|
|
help_text = help_texts.get(
|
|
command,
|
|
"""Available commands:
|
|
• lock (LOCK VAULT FOR SECURITY!)
|
|
• add name username password;
|
|
• remove name;
|
|
• update name field new_value;
|
|
• help""",
|
|
)
|
|
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-help",
|
|
child=Box(
|
|
orientation="h",
|
|
spacing=12,
|
|
children=[
|
|
Label(
|
|
label=help_text,
|
|
style_classes="password-help-text",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _show_error_message(self, message: str) -> None:
|
|
self.handler.slot_ready(
|
|
Button(
|
|
style_classes="app-slot password-error",
|
|
child=Box(
|
|
orientation="h",
|
|
spacing=12,
|
|
children=[
|
|
Label(
|
|
label=message,
|
|
style_classes="password-error-text",
|
|
h_align="center",
|
|
),
|
|
],
|
|
),
|
|
on_clicked=lambda *_: self.handler.launched(),
|
|
),
|
|
"passwords",
|
|
)
|
|
self.handler.done()
|
|
|
|
def _refresh_password_list(self) -> None:
|
|
self.current_slots.clear()
|
|
if hasattr(self.handler, "clear_viewport"):
|
|
self.handler.clear_viewport()
|
|
idle_add(self._show_password_list)
|
|
|
|
def _show_password_list(self) -> None:
|
|
self.handler.start("passwords")
|
|
self._load_and_display_passwords("")
|
|
|
|
def handle_external(self, command: str, args: str) -> None:
|
|
# Map external commands if needed
|
|
pass
|