add checkboxes to keyfileframe

This commit is contained in:
2025-09-15 11:27:17 +02:00
parent 9406d3f0e2
commit 3a59bccddc
2 changed files with 162 additions and 74 deletions

View File

@@ -94,33 +94,62 @@ class EncryptionManager:
return os.path.join(pybackup_dir, container_filename) return os.path.join(pybackup_dir, container_filename)
def get_key_file_path(self, base_dest_path: str, profile_name: str) -> str: def get_key_file_path(self, base_dest_path: str, profile_name: str) -> str:
pybackup_dir = os.path.join(base_dest_path, "pybackup") # base_dest_path is ignored for security. Keyfiles are stored centrally.
secure_key_dir = "/usr/local/etc/luks"
key_filename = f"luks_{profile_name}.keyfile" key_filename = f"luks_{profile_name}.keyfile"
return os.path.join(pybackup_dir, key_filename) return os.path.join(secure_key_dir, key_filename)
def create_and_add_key_file(self, base_dest_path: str, profile_name: str, password: str) -> Optional[str]: def create_and_add_key_file(self, base_dest_path: str, profile_name: str, password: str) -> Optional[str]:
# Get the new secure path for the keyfile
key_file_path = self.get_key_file_path(base_dest_path, profile_name)
secure_key_dir = os.path.dirname(key_file_path)
# Container path remains the same, on the destination drive
container_path = self.get_container_path(base_dest_path, profile_name)
# This script will be executed as root. It creates the secure directory,
# generates the keyfile, sets its permissions, and adds it to the LUKS container.
# Using shlex.quote to be safe with paths
script = f"""
mkdir -p {shlex.quote(secure_key_dir)} && \
dd if=/dev/urandom of={shlex.quote(key_file_path)} bs=1 count=4096 && \
chmod 0400 {shlex.quote(key_file_path)} && \
cryptsetup luksAddKey {shlex.quote(container_path)} {shlex.quote(key_file_path)}
"""
if self._execute_as_root(script, password):
self.logger.log(
f"Successfully created keyfile at {key_file_path} and added to LUKS container.")
return key_file_path
else:
self.logger.log(
"Failed to create and add key file. The script might have failed midway. Attempting cleanup.")
# Attempt to clean up the keyfile if the script failed, as we can't be sure at which stage it failed.
cleanup_script = f"rm -f {shlex.quote(key_file_path)}"
self._execute_as_root(cleanup_script)
return None
def delete_key_file(self, base_dest_path: str, profile_name: str, password: str) -> bool:
key_file_path = self.get_key_file_path(base_dest_path, profile_name) key_file_path = self.get_key_file_path(base_dest_path, profile_name)
container_path = self.get_container_path(base_dest_path, profile_name) container_path = self.get_container_path(base_dest_path, profile_name)
try:
with open(key_file_path, 'wb') as f:
f.write(os.urandom(4096))
os.chmod(key_file_path, 0o400)
self.logger.log(f"Generated new key file at {key_file_path}")
script = f'cryptsetup luksAddKey "{container_path}" "{key_file_path}"' if not os.path.exists(key_file_path):
if self._execute_as_root(script, password): self.logger.log(f"Keyfile for profile {profile_name} does not exist. Nothing to delete.")
self.logger.log( return True # Considered a success as the desired state is "deleted"
"Successfully added key file to LUKS container.")
return key_file_path # This script removes the key from the LUKS container and then deletes the keyfile.
else: # It requires a valid password for the container to authorize the key removal.
self.logger.log("Failed to add key file to LUKS container.") script = f"""
os.remove(key_file_path) cryptsetup luksRemoveKey {shlex.quote(container_path)} {shlex.quote(key_file_path)} && \
return None rm -f {shlex.quote(key_file_path)}
except Exception as e: """
self.logger.log(f"Error creating key file: {e}")
if os.path.exists(key_file_path): if self._execute_as_root(script, password):
os.remove(key_file_path) self.logger.log(f"Successfully removed keyfile for profile {profile_name}.")
return None return True
else:
self.logger.log(f"Failed to remove keyfile for profile {profile_name}.")
return False
def _get_password_and_keyfile(self, base_dest_path: str, profile_name: str, username: str) -> Tuple[Optional[str], Optional[str]]: def _get_password_and_keyfile(self, base_dest_path: str, profile_name: str, username: str) -> Tuple[Optional[str], Optional[str]]:
"""Gets the password (from cache, keyring, or user) or the keyfile path.""" """Gets the password (from cache, keyring, or user) or the keyfile path."""

View File

@@ -1,6 +1,7 @@
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
import os import os
import glob
from pathlib import Path from pathlib import Path
from core.pbp_app_config import AppConfig, Msg from core.pbp_app_config import AppConfig, Msg
@@ -17,6 +18,7 @@ class AdvancedSettingsFrame(ttk.Frame):
self.config_manager = config_manager self.config_manager = config_manager
self.app_instance = app_instance self.app_instance = app_instance
self.current_view_index = 0 self.current_view_index = 0
self.keyfile_profile_widgets = {}
nav_frame = ttk.Frame(self) nav_frame = ttk.Frame(self)
nav_frame.pack(fill=tk.X, padx=10, pady=(0, 5)) nav_frame.pack(fill=tk.X, padx=10, pady=(0, 5))
@@ -169,39 +171,16 @@ class AdvancedSettingsFrame(ttk.Frame):
# --- Keyfile Settings Frame --- # --- Keyfile Settings Frame ---
self.keyfile_settings_frame = ttk.LabelFrame( self.keyfile_settings_frame = ttk.LabelFrame(
view_container, text=Msg.STR["automation_settings_title"], padding=10) view_container, text=Msg.STR["automation_settings_title"], padding=10)
keyfile_info_label = ttk.Label( keyfile_info_label = ttk.Label(
self.keyfile_settings_frame, text=Msg.STR["keyfile_automation_info"], justify="left", wraplength=750) self.keyfile_settings_frame, text=Msg.STR["keyfile_automation_info"], justify="left", wraplength=750)
keyfile_info_label.grid( keyfile_info_label.pack(fill=tk.X, pady=(5, 15))
row=0, column=0, columnspan=3, sticky="w", padx=5, pady=(5, 15))
# Display for current destination scan_button = ttk.Button(self.keyfile_settings_frame, text="Scan Destination for Encrypted Profiles", command=self._populate_keyfile_profiles_view)
dest_frame = ttk.Frame(self.keyfile_settings_frame) scan_button.pack(pady=5)
dest_frame.grid(row=1, column=0, columnspan=3, sticky="ew", padx=5, pady=5)
dest_frame.columnconfigure(1, weight=1)
ttk.Label(dest_frame, text=f"{Msg.STR['current_destination']}:", font=("Ubuntu", 11, "bold")).grid(row=0, column=0, sticky="w")
self.keyfile_dest_path_label = ttk.Label(dest_frame, text=Msg.STR["no_destination_selected"], foreground="gray")
self.keyfile_dest_path_label.grid(row=0, column=1, sticky="w", padx=5)
# Action Button self.keyfile_profiles_frame = ttk.Frame(self.keyfile_settings_frame)
key_file_button = ttk.Button( self.keyfile_profiles_frame.pack(fill=tk.BOTH, expand=True, pady=10)
self.keyfile_settings_frame, text=Msg.STR["create_add_key_file"], command=self._create_key_file)
key_file_button.grid(row=2, column=0, padx=5, pady=10)
# Status Label
self.key_file_status_var = tk.StringVar(value="")
key_file_status_label = ttk.Label(
self.keyfile_settings_frame, textvariable=self.key_file_status_var, foreground="gray")
key_file_status_label.grid(
row=2, column=1, padx=5, pady=10, sticky="w")
# Sudoers Info
sudoers_info_label = ttk.Label(
self.keyfile_settings_frame, text=Msg.STR["sudoers_info_text"], justify="left")
sudoers_info_label.grid(
row=3, column=0, columnspan=3, sticky="w", padx=5, pady=10)
self.keyfile_settings_frame.columnconfigure(1, weight=1)
# --- Bottom Buttons (for most views) --- # --- Bottom Buttons (for most views) ---
self.bottom_button_frame = ttk.Frame(self) self.bottom_button_frame = ttk.Frame(self)
@@ -222,7 +201,6 @@ class AdvancedSettingsFrame(ttk.Frame):
self._load_animation_settings() self._load_animation_settings()
self._load_backup_defaults() self._load_backup_defaults()
self._load_manual_excludes() self._load_manual_excludes()
self._update_key_file_status()
self._switch_view(self.current_view_index) self._switch_view(self.current_view_index)
@@ -259,58 +237,140 @@ class AdvancedSettingsFrame(ttk.Frame):
self.config_manager.set_setting("use_trash_bin", use_trash) self.config_manager.set_setting("use_trash_bin", use_trash)
self.config_manager.set_setting("no_trash_bin", no_trash) self.config_manager.set_setting("no_trash_bin", no_trash)
def _create_key_file(self): def _populate_keyfile_profiles_view(self):
header = self.app_instance.header_frame # Clear existing widgets
destination = self.app_instance.destination_path for widget in self.keyfile_profiles_frame.winfo_children():
widget.destroy()
self.keyfile_profile_widgets.clear()
destination = self.app_instance.destination_path
if not destination: if not destination:
MessageDialog(message_type="error", title=Msg.STR["error"], MessageDialog(message_type="error", title=Msg.STR["error"],
text=Msg.STR["select_destination_first"]).show() text=Msg.STR["select_destination_first"]).show()
return return
pybackup_dir = os.path.join(destination, "pybackup") pybackup_dir = os.path.join(destination, "pybackup")
container_path = os.path.join(pybackup_dir, "pybackup_encrypted.luks") if not os.path.isdir(pybackup_dir):
if not os.path.exists(container_path): ttk.Label(self.keyfile_profiles_frame, text="No 'pybackup' directory found at destination.").pack()
MessageDialog(message_type="error", title=Msg.STR["error"],
text=Msg.STR["err_no_encrypted_container"]).show()
return return
search_pattern = os.path.join(pybackup_dir, "pybackup_encrypted_*.luks")
found_containers = glob.glob(search_pattern)
if not found_containers:
ttk.Label(self.keyfile_profiles_frame, text="No encrypted profiles found at destination.").pack()
return
profiles = []
for container in found_containers:
filename = os.path.basename(container)
profile_name = filename.replace("pybackup_encrypted_", "").replace(".luks", "")
if profile_name:
profiles.append(profile_name)
# Master "All" checkbox
all_var = tk.BooleanVar()
all_check = ttk.Checkbutton(self.keyfile_profiles_frame, text="All Profiles", variable=all_var, style="Switch.TCheckbutton",
command=lambda v=all_var: self._on_master_keyfile_toggle(v, profiles))
all_check.pack(anchor="w", padx=5, pady=5)
ttk.Separator(self.keyfile_profiles_frame, orient=tk.HORIZONTAL).pack(fill=tk.X, pady=5)
# Individual profile checkboxes
for profile_name in sorted(profiles):
profile_frame = ttk.Frame(self.keyfile_profiles_frame)
profile_frame.pack(fill=tk.X, padx=5, pady=2)
var = tk.BooleanVar()
check = ttk.Checkbutton(profile_frame, text=profile_name, variable=var, style="Switch.TCheckbutton")
check.pack(side=tk.LEFT)
# Use a lambda with default arguments to capture the current values
check.config(command=lambda p=profile_name, v=var: self._on_keyfile_checkbox_toggle(p, v))
status_label = ttk.Label(profile_frame, text="", foreground="gray")
status_label.pack(side=tk.LEFT, padx=10)
self.keyfile_profile_widgets[profile_name] = {"var": var, "status_label": status_label}
self._update_single_keyfile_status(profile_name)
def _on_master_keyfile_toggle(self, var, profiles):
if not var.get():
return # Do nothing on uncheck for safety
password_dialog = PasswordDialog( password_dialog = PasswordDialog(
self, title=Msg.STR["enter_existing_password_title"], confirm=False, translations=Msg.STR) self, title=Msg.STR["enter_existing_password_title"], confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password() password, _ = password_dialog.get_password()
if not password: if not password:
var.set(False) # Uncheck the master switch
return return
for profile_name in profiles:
self._create_key_file_for_profile(profile_name, password)
def _on_keyfile_checkbox_toggle(self, profile_name, var):
if var.get(): # If checked, create key
self._create_key_file_for_profile(profile_name)
else: # If unchecked, delete key
self._delete_key_file_for_profile(profile_name)
def _create_key_file_for_profile(self, profile_name, password=None):
header = self.app_instance.header_frame
destination = self.app_instance.destination_path
if not password:
password_dialog = PasswordDialog(
self, title=f"{Msg.STR['enter_existing_password_title']} for {profile_name}", confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password()
if not password:
self._update_single_keyfile_status(profile_name) # Revert checkbox state
return
key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file( key_file_path = self.app_instance.backup_manager.encryption_manager.create_and_add_key_file(
destination, password) destination, profile_name, password)
if key_file_path: if key_file_path:
header.show_temporary_message(Msg.STR["keyfile_creation_success"]) header.show_temporary_message(Msg.STR["keyfile_creation_success"])
else: else:
header.show_temporary_message(Msg.STR["keyfile_creation_failed"]) header.show_temporary_message(Msg.STR["keyfile_creation_failed"])
self._update_single_keyfile_status(profile_name)
self._update_key_file_status() def _delete_key_file_for_profile(self, profile_name):
header = self.app_instance.header_frame
def _update_key_file_status(self):
destination = self.app_instance.destination_path destination = self.app_instance.destination_path
if not destination:
self.keyfile_dest_path_label.config(text=Msg.STR["no_destination_selected"], foreground="gray") password_dialog = PasswordDialog(
self.key_file_status_var.set(Msg.STR["keyfile_status_unknown"]) self, title=f"Password to delete key for {profile_name}", confirm=False, translations=Msg.STR)
password, _ = password_dialog.get_password()
if not password:
self._update_single_keyfile_status(profile_name) # Revert checkbox state
return return
self.keyfile_dest_path_label.config(text=destination, foreground="#FFFFFF") # Adapt color to theme success = self.app_instance.backup_manager.encryption_manager.delete_key_file(
destination, profile_name, password)
source_name = self.app_instance.left_canvas_data.get('folder') or "system" if success:
profile_name = "system" if source_name == "Computer" else source_name header.show_temporary_message("Keyfile successfully deleted.")
else:
header.show_temporary_message("Failed to delete keyfile.")
self._update_single_keyfile_status(profile_name)
def _update_single_keyfile_status(self, profile_name):
widgets = self.keyfile_profile_widgets.get(profile_name)
if not widgets:
return
destination = self.app_instance.destination_path
key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path( key_file_path = self.app_instance.backup_manager.encryption_manager.get_key_file_path(
destination, profile_name) destination, profile_name)
if os.path.exists(key_file_path): if os.path.exists(key_file_path):
self.key_file_status_var.set(Msg.STR["keyfile_exists"].format(key_file_path=key_file_path)) widgets["var"].set(True)
widgets["status_label"].config(text="Keyfile exists")
else: else:
self.key_file_status_var.set(Msg.STR["keyfile_not_found_for_dest"]) widgets["var"].set(False)
widgets["status_label"].config(text="")
def _switch_view(self, index): def _switch_view(self, index):
self.current_view_index = index self.current_view_index = index
@@ -322,8 +382,7 @@ class AdvancedSettingsFrame(ttk.Frame):
self.animation_settings_frame.pack_forget() self.animation_settings_frame.pack_forget()
self.backup_defaults_frame.pack_forget() self.backup_defaults_frame.pack_forget()
# Show/hide the main action buttons based on the view if index in [1, 2]:
if index in [1, 2]: # Manual Excludes and Keyfile Settings views
self.bottom_button_frame.pack_forget() self.bottom_button_frame.pack_forget()
else: else:
self.bottom_button_frame.pack(pady=10) self.bottom_button_frame.pack(pady=10)
@@ -339,7 +398,7 @@ class AdvancedSettingsFrame(ttk.Frame):
elif index == 2: elif index == 2:
self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True) self.keyfile_settings_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text="") self.info_label.config(text="")
self._update_key_file_status() self._populate_keyfile_profiles_view()
elif index == 3: elif index == 3:
self.animation_settings_frame.pack(fill=tk.BOTH, expand=True) self.animation_settings_frame.pack(fill=tk.BOTH, expand=True)
self.info_label.config(text="") self.info_label.config(text="")