diff --git a/core/file_protection.py b/core/file_protection.py
new file mode 100644
index 0000000..c620ce6
--- /dev/null
+++ b/core/file_protection.py
@@ -0,0 +1,759 @@
+"""
+File Protection Manager
+Protects critical files (recovery codes, config) from deletion/modification when monitoring is active.
+
+Platform-specific implementations:
+- Windows: SetFileAttributesW (HIDDEN + SYSTEM + READONLY)
+- Linux: chattr +i (immutable flag) or restrictive permissions
+"""
+
+import os
+import sys
+import stat
+from typing import List, Tuple, Optional
+
+# Platform detection
+IS_WINDOWS = sys.platform == 'win32'
+IS_LINUX = sys.platform.startswith('linux')
+
+if IS_WINDOWS:
+ try:
+ import ctypes
+ from ctypes import windll, wintypes
+ WINDOWS_AVAILABLE = True
+ except ImportError:
+ WINDOWS_AVAILABLE = False
+ print("[FileProtection] โ ๏ธ Windows ctypes not available")
+else:
+ WINDOWS_AVAILABLE = False
+
+
+class FileProtectionManager:
+ """
+ Manages file protection to prevent tampering with critical files during monitoring.
+
+ Protection Methods:
+ - Windows: Hidden + System + ReadOnly attributes via SetFileAttributesW
+ - Linux: Immutable flag via chattr +i (requires sudo) or restrictive permissions
+
+ Files Protected:
+ - recovery_codes.json
+ - encrypted_password.bin
+ - fadcrypt_config.json (optional)
+ """
+
+ # Windows file attribute constants
+ FILE_ATTRIBUTE_READONLY = 0x00000001
+ FILE_ATTRIBUTE_HIDDEN = 0x00000002
+ FILE_ATTRIBUTE_SYSTEM = 0x00000004
+ FILE_ATTRIBUTE_NORMAL = 0x00000080
+
+ def __init__(self):
+ """Initialize file protection manager"""
+ self.protected_files: List[str] = []
+ self.original_attributes: dict = {} # Store original attributes for restoration
+ self.file_locks: dict = {} # Store open file descriptors for locking (Linux)
+
+ print(f"[FileProtection] Initialized on {sys.platform}")
+ print(f"[FileProtection] Windows mode: {IS_WINDOWS}")
+ print(f"[FileProtection] Linux mode: {IS_LINUX}")
+
+ def protect_file(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Protect a file from deletion/modification.
+
+ Args:
+ file_path: Full path to file to protect
+
+ Returns:
+ Tuple of (success: bool, error_message: Optional[str])
+ """
+ if not os.path.exists(file_path):
+ return False, f"File not found: {file_path}"
+
+ try:
+ # Store original attributes
+ self._store_original_attributes(file_path)
+
+ if IS_WINDOWS:
+ return self._protect_file_windows(file_path)
+ elif IS_LINUX:
+ return self._protect_file_linux(file_path)
+ else:
+ return False, f"Unsupported platform: {sys.platform}"
+
+ except Exception as e:
+ return False, f"Exception protecting file: {e}"
+
+ def unprotect_file(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Remove protection from a file.
+
+ Args:
+ file_path: Full path to file to unprotect
+
+ Returns:
+ Tuple of (success: bool, error_message: Optional[str])
+ """
+ if not os.path.exists(file_path):
+ return False, f"File not found: {file_path}"
+
+ try:
+ if IS_WINDOWS:
+ return self._unprotect_file_windows(file_path)
+ elif IS_LINUX:
+ return self._unprotect_file_linux(file_path)
+ else:
+ return False, f"Unsupported platform: {sys.platform}"
+
+ except Exception as e:
+ return False, f"Exception unprotecting file: {e}"
+
+ def protect_multiple_files(self, file_paths: List[str]) -> Tuple[int, List[str]]:
+ """
+ Protect multiple files at once.
+
+ On Linux: Uses batch chattr +i with single pkexec prompt for all files.
+ On Windows: Protects each file individually.
+
+ Args:
+ file_paths: List of file paths to protect
+
+ Returns:
+ Tuple of (success_count: int, errors: List[str])
+ """
+ # Filter existing files
+ existing_files = [f for f in file_paths if os.path.exists(f)]
+
+ if not existing_files:
+ return 0, ["No files found to protect"]
+
+ # Store original attributes for all files
+ for file_path in existing_files:
+ self._store_original_attributes(file_path)
+
+ # Linux: Use batch protection with single pkexec prompt
+ if IS_LINUX:
+ return self._protect_multiple_files_linux_batch(existing_files)
+
+ # Windows: Protect all files quickly (no UAC needed for attributes)
+ success_count = 0
+ errors = []
+
+ print(f"[FileProtection] Protecting {len(existing_files)} files...")
+
+ for file_path in existing_files:
+ success, error = self.protect_file(file_path)
+ if success:
+ success_count += 1
+ self.protected_files.append(file_path)
+ print(f"[FileProtection] โ
Protected: {os.path.basename(file_path)}")
+ else:
+ errors.append(f"{os.path.basename(file_path)}: {error}")
+ print(f"[FileProtection] โ Failed to protect: {os.path.basename(file_path)} - {error}")
+
+ if success_count > 0:
+ print(f"[FileProtection] ๐ {success_count} files protected (HIDDEN + SYSTEM + READONLY)")
+
+ return success_count, errors
+
+ def unprotect_all_files(self) -> Tuple[int, List[str]]:
+ """
+ Remove protection from all previously protected files.
+
+ On Linux: Uses batch chattr -i with single authorization for all files.
+ On Windows: Unprotects each file individually.
+
+ Returns:
+ Tuple of (success_count: int, errors: List[str])
+ """
+ if not self.protected_files:
+ return 0, []
+
+ success_count = 0
+ errors = []
+
+ # Linux: Use batch unprotection with single authorization
+ if IS_LINUX and len(self.protected_files) > 1:
+ batch_success = self._try_batch_chattr_with_pkexec(self.protected_files, set_immutable=False)
+
+ if not batch_success:
+ batch_success = self._try_batch_chattr_with_sudo(self.protected_files, set_immutable=False)
+
+ if batch_success:
+ # Verify and remove from protected list
+ for file_path in self.protected_files[:]:
+ filename = os.path.basename(file_path)
+ if not self._verify_immutable_flag(file_path):
+ success_count += 1
+ self.protected_files.remove(file_path)
+ print(f"[FileProtection] โ
Unprotected: {filename}")
+
+ # Restore permissions
+ try:
+ if file_path in self.original_attributes:
+ mode = self.original_attributes[file_path]
+ del self.original_attributes[file_path]
+ else:
+ mode = stat.S_IRUSR | stat.S_IWUSR # 600
+ os.chmod(file_path, mode)
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ chmod failed for {filename}: {e}")
+ else:
+ errors.append(f"{filename}: Still immutable")
+
+ print(f"[FileProtection] ๐ Batch unprotected {success_count} files")
+ return success_count, errors
+
+ # Fallback or Windows: Unprotect each file individually
+ for file_path in self.protected_files[:]:
+ success, error = self.unprotect_file(file_path)
+ if success:
+ success_count += 1
+ self.protected_files.remove(file_path)
+ print(f"[FileProtection] โ
Unprotected: {os.path.basename(file_path)}")
+ else:
+ errors.append(f"{os.path.basename(file_path)}: {error}")
+ print(f"[FileProtection] โ Failed to unprotect: {os.path.basename(file_path)} - {error}")
+
+ return success_count, errors
+
+ def _store_original_attributes(self, file_path: str):
+ """Store original file attributes for restoration"""
+ try:
+ if IS_WINDOWS and WINDOWS_AVAILABLE:
+ attrs = windll.kernel32.GetFileAttributesW(file_path)
+ self.original_attributes[file_path] = attrs
+ elif IS_LINUX:
+ st = os.stat(file_path)
+ self.original_attributes[file_path] = st.st_mode
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ Could not store original attributes: {e}")
+
+ # ========== WINDOWS IMPLEMENTATION ==========
+
+ def _protect_file_windows(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Protect file on Windows using SetFileAttributesW.
+
+ Sets attributes: HIDDEN + SYSTEM + READONLY
+ This makes the file harder to delete and hidden from normal view.
+ """
+ if not WINDOWS_AVAILABLE:
+ return False, "Windows ctypes not available"
+
+ try:
+ # Combine attributes: Hidden + System + ReadOnly
+ attributes = (
+ self.FILE_ATTRIBUTE_HIDDEN |
+ self.FILE_ATTRIBUTE_SYSTEM |
+ self.FILE_ATTRIBUTE_READONLY
+ )
+
+ # Set file attributes
+ result = windll.kernel32.SetFileAttributesW(file_path, attributes)
+
+ if result == 0:
+ error_code = windll.kernel32.GetLastError()
+ return False, f"SetFileAttributesW failed with error code: {error_code}"
+
+ print(f"[FileProtection] Windows: Set HIDDEN + SYSTEM + READONLY on {os.path.basename(file_path)}")
+ return True, None
+
+ except Exception as e:
+ return False, f"Windows protection failed: {e}"
+
+ def _unprotect_file_windows(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Remove protection from file on Windows.
+
+ Restores original attributes or sets to NORMAL.
+ """
+ if not WINDOWS_AVAILABLE:
+ return False, "Windows ctypes not available"
+
+ try:
+ # Restore original attributes if available, otherwise set to NORMAL
+ if file_path in self.original_attributes:
+ attributes = self.original_attributes[file_path]
+ del self.original_attributes[file_path]
+ else:
+ attributes = self.FILE_ATTRIBUTE_NORMAL
+
+ # Set file attributes
+ result = windll.kernel32.SetFileAttributesW(file_path, attributes)
+
+ if result == 0:
+ error_code = windll.kernel32.GetLastError()
+ return False, f"SetFileAttributesW failed with error code: {error_code}"
+
+ print(f"[FileProtection] Windows: Restored attributes on {os.path.basename(file_path)}")
+ return True, None
+
+ except Exception as e:
+ return False, f"Windows unprotection failed: {e}"
+
+ # ========== LINUX IMPLEMENTATION ==========
+
+ def _protect_file_linux(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Protect file on Linux using immutable flag (chattr +i).
+
+ Protection hierarchy:
+ 1. chattr +i with pkexec (PolicyKit GUI prompt - BEST)
+ 2. chattr +i with sudo (terminal prompt - GOOD)
+ 3. chattr +i without elevation (will likely fail - WEAK)
+ 4. chmod 400 + fcntl lock (detectable but bypassable - LAST RESORT)
+
+ Returns:
+ Tuple of (success: bool, error_message: Optional[str])
+ """
+ filename = os.path.basename(file_path)
+
+ # Primary method: chattr +i with pkexec (GUI prompt)
+ success, error = self._try_chattr_with_pkexec(file_path, set_immutable=True)
+ if success:
+ print(f"[FileProtection] โ
IMMUTABLE: {filename} (chattr +i via pkexec)")
+ print(f"[FileProtection] ๐ File CANNOT be deleted, even by root")
+ return True, None
+ else:
+ print(f"[FileProtection] โ ๏ธ pkexec chattr failed: {error}")
+
+ # Fallback 1: chattr +i with sudo (terminal prompt)
+ success, error = self._try_chattr_with_sudo(file_path, set_immutable=True)
+ if success:
+ print(f"[FileProtection] โ
IMMUTABLE: {filename} (chattr +i via sudo)")
+ print(f"[FileProtection] ๐ File CANNOT be deleted, even by root")
+ return True, None
+ else:
+ print(f"[FileProtection] โ ๏ธ sudo chattr failed: {error}")
+
+ # Fallback 2: chattr +i without elevation (will likely fail)
+ success, error = self._try_chattr_immutable(file_path, set_immutable=True)
+ if success:
+ print(f"[FileProtection] โ
IMMUTABLE: {filename} (chattr +i)")
+ print(f"[FileProtection] ๐ File CANNOT be deleted, even by root")
+ return True, None
+ else:
+ print(f"[FileProtection] โ ๏ธ chattr without elevation failed: {error}")
+
+ # Last resort: chmod 400 + fcntl lock (WEAK - only detectable by file monitor)
+ print(f"[FileProtection] โ ๏ธ CRITICAL: Could not set immutable flag!")
+ print(f"[FileProtection] โ ๏ธ Falling back to chmod 400 + file lock (WEAK protection)")
+
+ try:
+ # Set restrictive permissions
+ os.chmod(file_path, stat.S_IRUSR) # 400 - read-only for owner
+ print(f"[FileProtection] ๐ Permissions: 400 (read-only) on {filename}")
+ except Exception as e:
+ print(f"[FileProtection] โ chmod 400 failed: {e}")
+
+ # Keep file descriptor open with lock (advisory only)
+ try:
+ import fcntl
+ fd = open(file_path, 'r')
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ self.file_locks[file_path] = fd
+ print(f"[FileProtection] ๐ Advisory lock acquired on {filename}")
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ File lock failed: {e}")
+
+ print(f"[FileProtection] โ ๏ธ File CAN be deleted with rm/sudo - monitor will auto-restore")
+ return True, None # Still return success to not block monitoring
+
+ def _unprotect_file_linux(self, file_path: str) -> Tuple[bool, Optional[str]]:
+ """
+ Remove protection from file on Linux.
+
+ Removes immutable flag and restores permissions.
+ """
+ filename = os.path.basename(file_path)
+
+ # Release file lock if held (advisory lock from fallback)
+ if file_path in self.file_locks:
+ try:
+ import fcntl
+ fd = self.file_locks[file_path]
+ fcntl.flock(fd, fcntl.LOCK_UN)
+ fd.close()
+ del self.file_locks[file_path]
+ print(f"[FileProtection] ๐ Released advisory lock on {filename}")
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ Failed to release lock: {e}")
+
+ # Remove immutable flag (try all methods)
+ immutable_removed = False
+
+ # Try pkexec first (GUI prompt)
+ success, error = self._try_chattr_with_pkexec(file_path, set_immutable=False)
+ if success:
+ print(f"[FileProtection] โ
Immutable flag removed: {filename} (pkexec)")
+ immutable_removed = True
+ else:
+ # Try sudo (terminal prompt)
+ success, error = self._try_chattr_with_sudo(file_path, set_immutable=False)
+ if success:
+ print(f"[FileProtection] โ
Immutable flag removed: {filename} (sudo)")
+ immutable_removed = True
+ else:
+ # Try without elevation
+ success, error = self._try_chattr_immutable(file_path, set_immutable=False)
+ if success:
+ print(f"[FileProtection] โ
Immutable flag removed: {filename}")
+ immutable_removed = True
+ else:
+ print(f"[FileProtection] โ ๏ธ Could not remove immutable flag: {error}")
+
+ # Restore original permissions
+ try:
+ if file_path in self.original_attributes:
+ mode = self.original_attributes[file_path]
+ del self.original_attributes[file_path]
+ else:
+ mode = stat.S_IRUSR | stat.S_IWUSR # 600 (rw-------)
+
+ os.chmod(file_path, mode)
+ print(f"[FileProtection] ๐ Restored permissions on {filename}")
+ return True, None
+
+ except Exception as e:
+ if immutable_removed:
+ # Immutable flag removed but chmod failed - still partially successful
+ print(f"[FileProtection] โ ๏ธ Immutable removed but chmod failed: {e}")
+ return True, None
+ else:
+ return False, f"Unprotection failed: {e}"
+
+ def _protect_multiple_files_linux_batch(self, file_paths: List[str]) -> Tuple[int, List[str]]:
+ """
+ Protect multiple files with single pkexec authorization prompt (Linux only).
+
+ Uses batch chattr +i command to set immutable flag on all files at once.
+ This provides much better UX - single authorization instead of 3+ prompts.
+
+ Args:
+ file_paths: List of file paths to protect
+
+ Returns:
+ Tuple of (success_count: int, errors: List[str])
+ """
+ if not file_paths:
+ return 0, ["No files to protect"]
+
+ success_count = 0
+ errors = []
+
+ # Try batch protection with pkexec (single GUI prompt for all files)
+ batch_success = self._try_batch_chattr_with_pkexec(file_paths, set_immutable=True)
+
+ if batch_success:
+ # Verify all files got immutable flag
+ for file_path in file_paths:
+ filename = os.path.basename(file_path)
+ if self._verify_immutable_flag(file_path):
+ success_count += 1
+ self.protected_files.append(file_path)
+ print(f"[FileProtection] โ
IMMUTABLE: {filename} (batch chattr +i)")
+ else:
+ errors.append(f"{filename}: Immutable flag not set")
+ print(f"[FileProtection] โ Failed verification: {filename}")
+
+ if success_count > 0:
+ print(f"[FileProtection] ๐ {success_count} files CANNOT be deleted, even by root")
+ return success_count, errors
+
+ # Fallback: Try batch with sudo
+ print(f"[FileProtection] โ ๏ธ pkexec batch failed, trying sudo...")
+ batch_success = self._try_batch_chattr_with_sudo(file_paths, set_immutable=True)
+
+ if batch_success:
+ for file_path in file_paths:
+ filename = os.path.basename(file_path)
+ if self._verify_immutable_flag(file_path):
+ success_count += 1
+ self.protected_files.append(file_path)
+ print(f"[FileProtection] โ
IMMUTABLE: {filename} (batch sudo)")
+ else:
+ errors.append(f"{filename}: Immutable flag not set")
+
+ if success_count > 0:
+ print(f"[FileProtection] ๐ {success_count} files CANNOT be deleted, even by root")
+ return success_count, errors
+
+ # Last resort: Protect each file individually with fallback methods
+ print(f"[FileProtection] โ ๏ธ Batch protection failed, falling back to individual protection...")
+
+ for file_path in file_paths:
+ success, error = self._protect_file_linux(file_path)
+ if success:
+ success_count += 1
+ self.protected_files.append(file_path)
+ else:
+ errors.append(f"{os.path.basename(file_path)}: {error}")
+
+ return success_count, errors
+
+ def _try_batch_chattr_with_pkexec(self, file_paths: List[str], set_immutable: bool) -> bool:
+ """
+ Try to set/unset immutable flag on multiple files with single pkexec prompt.
+
+ Args:
+ file_paths: List of file paths
+ set_immutable: True to set +i, False to set -i
+
+ Returns:
+ True if command succeeded, False otherwise
+ """
+ try:
+ import subprocess
+
+ flag = "+i" if set_immutable else "-i"
+
+ # Build command: pkexec chattr +i file1 file2 file3...
+ cmd = ['pkexec', 'chattr', flag] + file_paths
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=30
+ )
+
+ if result.returncode == 0:
+ action = "protected" if set_immutable else "unprotected"
+ print(f"[FileProtection] โ
Batch {action} {len(file_paths)} files with single authorization")
+ return True
+ else:
+ stderr = result.stderr.strip()
+ if "dismissed" in stderr.lower() or "cancelled" in stderr.lower():
+ print(f"[FileProtection] โ ๏ธ User cancelled batch authorization")
+ else:
+ print(f"[FileProtection] โ ๏ธ Batch pkexec failed: {stderr}")
+ return False
+
+ except FileNotFoundError:
+ print(f"[FileProtection] โ ๏ธ pkexec not found")
+ return False
+ except subprocess.TimeoutExpired:
+ print(f"[FileProtection] โ ๏ธ pkexec timeout")
+ return False
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ Batch pkexec exception: {e}")
+ return False
+
+ def _try_batch_chattr_with_sudo(self, file_paths: List[str], set_immutable: bool) -> bool:
+ """
+ Try to set/unset immutable flag on multiple files with sudo.
+
+ Args:
+ file_paths: List of file paths
+ set_immutable: True to set +i, False to set -i
+
+ Returns:
+ True if command succeeded, False otherwise
+ """
+ try:
+ import subprocess
+
+ flag = "+i" if set_immutable else "-i"
+
+ # Build command: sudo -n chattr +i file1 file2 file3...
+ cmd = ['sudo', '-n', 'chattr', flag] + file_paths
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode == 0:
+ action = "protected" if set_immutable else "unprotected"
+ print(f"[FileProtection] โ
Batch {action} {len(file_paths)} files with sudo")
+ return True
+ else:
+ stderr = result.stderr.strip()
+ print(f"[FileProtection] โ ๏ธ Batch sudo failed: {stderr}")
+ return False
+
+ except Exception as e:
+ print(f"[FileProtection] โ ๏ธ Batch sudo exception: {e}")
+ return False
+
+ def _verify_immutable_flag(self, file_path: str) -> bool:
+ """
+ Verify that a file has the immutable flag set.
+
+ Args:
+ file_path: Path to file to check
+
+ Returns:
+ True if immutable flag is set, False otherwise
+ """
+ try:
+ import subprocess
+
+ result = subprocess.run(
+ ['lsattr', file_path],
+ capture_output=True,
+ text=True,
+ timeout=2
+ )
+
+ if result.returncode == 0:
+ # lsattr output format: "----i--------- /path/to/file"
+ # Check if 'i' flag is present in first column
+ output = result.stdout.strip()
+ if output and 'i' in output.split()[0]:
+ return True
+
+ return False
+
+ except Exception:
+ return False
+
+ def _try_chattr_with_pkexec(self, file_path: str, set_immutable: bool) -> Tuple[bool, Optional[str]]:
+ """
+ Try to set/unset immutable flag using pkexec (PolicyKit GUI prompt).
+
+ Args:
+ file_path: Path to file
+ set_immutable: True to set +i, False to set -i
+
+ Returns:
+ Tuple of (success: bool, error: Optional[str])
+ """
+ try:
+ import subprocess
+
+ flag = "+i" if set_immutable else "-i"
+ result = subprocess.run(
+ ['pkexec', 'chattr', flag, file_path],
+ capture_output=True,
+ text=True,
+ timeout=30 # Longer timeout for user to respond to GUI
+ )
+
+ if result.returncode == 0:
+ return True, None
+ else:
+ stderr = result.stderr.strip()
+ # Check if user cancelled
+ if "dismissed" in stderr.lower() or "cancelled" in stderr.lower():
+ return False, "User cancelled authorization"
+ return False, f"pkexec chattr failed: {stderr}"
+
+ except FileNotFoundError:
+ return False, "pkexec command not found"
+ except subprocess.TimeoutExpired:
+ return False, "pkexec timeout (user did not respond)"
+ except Exception as e:
+ return False, f"pkexec exception: {e}"
+
+ def _try_chattr_with_sudo(self, file_path: str, set_immutable: bool) -> Tuple[bool, Optional[str]]:
+ """
+ Try to set/unset immutable flag using sudo (terminal password prompt).
+
+ Args:
+ file_path: Path to file
+ set_immutable: True to set +i, False to set -i
+
+ Returns:
+ Tuple of (success: bool, error: Optional[str])
+ """
+ try:
+ import subprocess
+
+ flag = "+i" if set_immutable else "-i"
+ result = subprocess.run(
+ ['sudo', '-n', 'chattr', flag, file_path], # -n = non-interactive (fail if password needed)
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode == 0:
+ return True, None
+ else:
+ stderr = result.stderr.strip()
+ # Check if password required
+ if "password is required" in stderr.lower() or "sudo: a password" in stderr.lower():
+ return False, "Sudo requires password (run FadCrypt from terminal with sudo)"
+ return False, f"sudo chattr failed: {stderr}"
+
+ except FileNotFoundError:
+ return False, "sudo command not found"
+ except subprocess.TimeoutExpired:
+ return False, "sudo timeout"
+ except Exception as e:
+ return False, f"sudo exception: {e}"
+
+ def _try_chattr_immutable(self, file_path: str, set_immutable: bool) -> Tuple[bool, Optional[str]]:
+ """
+ Try to set/unset immutable flag using chattr (without elevation).
+
+ Args:
+ file_path: Path to file
+ set_immutable: True to set +i, False to set -i
+
+ Returns:
+ Tuple of (success: bool, error: Optional[str])
+ """
+ try:
+ import subprocess
+
+ flag = "+i" if set_immutable else "-i"
+ result = subprocess.run(
+ ['chattr', flag, file_path],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+
+ if result.returncode == 0:
+ return True, None
+ else:
+ return False, f"chattr failed: {result.stderr}"
+
+ except FileNotFoundError:
+ return False, "chattr command not found"
+ except Exception as e:
+ return False, f"chattr exception: {e}"
+
+ def get_protected_files(self) -> List[str]:
+ """
+ Get list of currently protected files.
+
+ Returns:
+ List of file paths
+ """
+ return self.protected_files.copy()
+
+ def is_file_protected(self, file_path: str) -> bool:
+ """
+ Check if a file is currently protected.
+
+ Args:
+ file_path: Path to file
+
+ Returns:
+ True if file is in protected list
+ """
+ return file_path in self.protected_files
+
+
+# Singleton instance
+_file_protection_manager: Optional[FileProtectionManager] = None
+
+
+def get_file_protection_manager() -> FileProtectionManager:
+ """
+ Get singleton instance of FileProtectionManager.
+
+ Returns:
+ FileProtectionManager instance
+ """
+ global _file_protection_manager
+ if _file_protection_manager is None:
+ _file_protection_manager = FileProtectionManager()
+ return _file_protection_manager
diff --git a/core/linux/file_lock_manager_linux.py b/core/linux/file_lock_manager_linux.py
index cdee780..a685232 100644
--- a/core/linux/file_lock_manager_linux.py
+++ b/core/linux/file_lock_manager_linux.py
@@ -85,6 +85,9 @@ def _get_processes_using_files(self, file_paths: List[str]) -> Dict[str, List[in
Returns dict mapping file_path to list of PIDs using that file.
PERFORMANCE: 12x faster than previous per-file fuser approach!
+
+ IMPORTANT: Excludes FadCrypt's own process to prevent self-termination when
+ file protection is active (file locks held for security).
"""
file_to_pids = {path: [] for path in file_paths}
@@ -92,15 +95,21 @@ def _get_processes_using_files(self, file_paths: List[str]) -> Dict[str, List[in
return file_to_pids
file_set = set(file_paths)
+ current_pid = os.getpid() # Get FadCrypt's own PID
try:
# SINGLE SCAN: Iterate through all processes once
for proc in psutil.process_iter(['pid', 'open_files']):
try:
+ pid = proc.info['pid']
+
+ # Skip FadCrypt's own process to prevent self-termination
+ if pid == current_pid:
+ continue
+
if proc.info['open_files']:
for file_info in proc.info['open_files']:
if file_info.path in file_set:
- pid = proc.info['pid']
if pid not in file_to_pids[file_info.path]:
file_to_pids[file_info.path].append(pid)
except (psutil.NoSuchProcess, psutil.AccessDenied):
diff --git a/core/password_manager.py b/core/password_manager.py
index a1bc660..3f04143 100644
--- a/core/password_manager.py
+++ b/core/password_manager.py
@@ -1,11 +1,12 @@
"""
Password Manager - Master Password Operations
-Handles master password creation, verification, and changes
+Handles master password creation, verification, changes, and recovery codes
"""
import os
-from typing import Optional, Callable
+from typing import Optional, Callable, List, Tuple
from .crypto_manager import CryptoManager
+from .recovery_manager import RecoveryCodeManager
class PasswordManager:
@@ -22,21 +23,28 @@ class PasswordManager:
cached_password: In-memory cache of password (bytes)
"""
- def __init__(self, password_file_path: str, crypto_manager: Optional[CryptoManager] = None):
+ def __init__(self, password_file_path: str, crypto_manager: Optional[CryptoManager] = None, recovery_codes_file_path: Optional[str] = None):
"""
Initialize the PasswordManager.
Args:
password_file_path: Full path to encrypted_password.bin file
- crypto_manager: Optional CryptoManager instance (creates new if None)
+ crypto_manager: Optional CryptoManager instance
+ recovery_codes_file_path: Full path to recovery_codes.json file
"""
self.password_file = password_file_path
self.crypto = crypto_manager or CryptoManager()
self.cached_password: Optional[bytes] = None
- # Log initialization
+ # Initialize recovery code manager if path provided
+ self.recovery_manager: Optional[RecoveryCodeManager] = None
+ if recovery_codes_file_path:
+ self.recovery_manager = RecoveryCodeManager(recovery_codes_file_path)
+
print(f"[PasswordManager] Initialized with password file: {password_file_path}")
print(f"[PasswordManager] Password file exists: {os.path.exists(password_file_path)}")
+ if self.recovery_manager:
+ print(f"[PasswordManager] Recovery codes available: {self.recovery_manager.has_recovery_codes()}")
def create_password(self, password: str) -> bool:
"""
@@ -201,3 +209,154 @@ def get_password_bytes(self) -> Optional[bytes]:
Cached password as bytes, or None if not cached
"""
return self.cached_password
+
+ # ==================== Recovery Code Methods ====================
+
+ def create_recovery_codes(self) -> Tuple[bool, Optional[List[str]]]:
+ """
+ Create recovery codes for the master password.
+
+ Should be called immediately after password creation.
+ User must write down the codes and store them safely.
+
+ Returns:
+ Tuple of (success: bool, codes: List[str] or None)
+ """
+ if not self.recovery_manager:
+ print("[PasswordManager] Recovery code manager not initialized")
+ return False, None
+
+ return self.recovery_manager.create_recovery_codes()
+
+ def verify_recovery_code(self, code: str) -> Tuple[bool, Optional[str]]:
+ """
+ Verify if a recovery code is valid and unused.
+
+ Args:
+ code: Recovery code to verify
+
+ Returns:
+ Tuple of (is_valid: bool, error_message: Optional[str])
+ """
+ if not self.recovery_manager:
+ return False, "Recovery codes not available"
+
+ return self.recovery_manager.verify_recovery_code(code)
+
+ def recover_password_with_code(
+ self,
+ recovery_code: str,
+ new_password: str,
+ cleanup_callback: Optional[Callable[[str], bool]] = None
+ ) -> Tuple[bool, Optional[str]]:
+ """
+ Recover access and reset password using a recovery code.
+
+ This is the core password recovery mechanism with hash-based security:
+ 1. Verify recovery code against saved hashes (NO password needed!)
+ 2. Mark code as used (one-time consumption)
+ 3. Delete old password file
+ 4. Delete old recovery codes
+ 5. Create new password
+ 6. Create new recovery codes
+ 7. Call cleanup callback (e.g., stop monitoring, unlock files)
+
+ CRITICAL SECURITY (Version 2.0 - Hash-Based):
+ - Recovery codes verified WITHOUT needing old password
+ - PBKDF2-HMAC-SHA256 hash verification (100k iterations)
+ - Code must match stored hash to proceed
+ - Code marked as used immediately after verification
+ - Old password file deleted (no bypass with old password)
+ - Old recovery codes deleted (used code won't work again)
+ - New recovery codes created with new password
+ - Even with hash file access, codes cannot be reversed
+
+ Args:
+ recovery_code: Recovery code provided by user
+ new_password: New master password
+ cleanup_callback: Optional callback to cleanup monitoring/files
+ Should accept new_password (str) and return success bool
+
+ Returns:
+ Tuple of (success: bool, error_message: Optional[str])
+ """
+ if not self.recovery_manager:
+ return False, "Recovery codes not available"
+
+ try:
+ if not self.recovery_manager.has_recovery_codes():
+ return False, "No recovery codes found. Please reset your password differently."
+
+ print("[PasswordManager] Starting password recovery process (hash-based)...")
+
+ # Step 1: Verify recovery code
+ print("[PasswordManager] Verifying recovery code against stored hashes...")
+ is_valid, error_msg = self.recovery_manager.verify_recovery_code(recovery_code)
+
+ if not is_valid:
+ print(f"[PasswordManager] Recovery code verification failed: {error_msg}")
+ return False, f"Invalid recovery code: {error_msg}"
+
+ print("[PasswordManager] Recovery code verified successfully")
+
+ # Step 2: Consume (mark as used) the recovery code immediately
+ print("[PasswordManager] Marking recovery code as used...")
+ consumed, consume_error = self.recovery_manager.consume_recovery_code(recovery_code)
+
+ if not consumed:
+ print(f"[PasswordManager] Failed to mark code as used: {consume_error}")
+ else:
+ print("[PasswordManager] Recovery code marked as used")
+
+ # Step 3: Delete old password file (cannot be recovered)
+ if os.path.exists(self.password_file):
+ try:
+ os.remove(self.password_file)
+ print("[PasswordManager] โ
Deleted old password file")
+ except Exception as e:
+ print(f"[PasswordManager] โ ๏ธ Failed to delete old password: {e}")
+
+ # Note: Recovery codes are kept - only the used code is marked as consumed
+ # Remaining unused codes can still be used for future password resets
+
+ # Step 4: Run cleanup callback (stop monitoring, unlock files, reset state)
+ if cleanup_callback:
+ print("[PasswordManager] Running cleanup callback...")
+ if not cleanup_callback(new_password):
+ print("[PasswordManager] โ ๏ธ Cleanup callback returned False")
+
+ # Step 5: Create new password
+ if not self.create_password(new_password):
+ return False, "Failed to create new password"
+
+ print("[PasswordManager] Password recovered and reset successfully")
+
+ return True, None
+
+ except Exception as e:
+ print(f"[PasswordManager] โ Error recovering password: {e}")
+ import traceback
+ traceback.print_exc()
+ return False, f"Error during recovery: {str(e)}"
+
+ def has_recovery_codes(self) -> bool:
+ """
+ Check if recovery codes are available.
+
+ Returns:
+ True if recovery codes are set, False otherwise
+ """
+ if not self.recovery_manager:
+ return False
+ return self.recovery_manager.has_recovery_codes()
+
+ def get_remaining_recovery_codes_count(self) -> Tuple[bool, Optional[int]]:
+ """
+ Get count of unused recovery codes.
+
+ Returns:
+ Tuple of (success: bool, count: Optional[int])
+ """
+ if not self.recovery_manager:
+ return False, None
+ return self.recovery_manager.get_remaining_codes_count()
diff --git a/core/recovery_manager.py b/core/recovery_manager.py
new file mode 100644
index 0000000..89c2e09
--- /dev/null
+++ b/core/recovery_manager.py
@@ -0,0 +1,423 @@
+"""
+Recovery Manager - Password Recovery Code Operations
+Handles generation, storage, verification, and consumption of recovery codes
+Provides secure password reset functionality when master password is forgotten
+
+SECURITY MODEL:
+- Recovery codes are hashed with PBKDF2-HMAC-SHA256 (100,000 iterations)
+- Each code has a unique random salt (32 bytes)
+- Hashes are stored WITHOUT password encryption
+- Even with hash file access, codes cannot be reversed
+- Brute force is computationally infeasible due to iteration count
+"""
+
+import os
+import json
+import secrets
+import string
+import hashlib
+from datetime import datetime
+from typing import Optional, List, Dict, Tuple
+
+
+class RecoveryCodeManager:
+ """
+ Manages password recovery codes for FadCrypt.
+
+ SECURITY ARCHITECTURE:
+ - Codes are hashed using PBKDF2-HMAC-SHA256 with 100,000 iterations
+ - Each code has a unique 32-byte random salt
+ - Hashes stored separately from encrypted password file
+ - Verification works WITHOUT needing the master password
+ - Hash file compromise does NOT reveal codes (cryptographically secure)
+ - Brute force attacks are computationally infeasible
+
+ Features:
+ - Generate 10 unique recovery codes per password setup
+ - Store code hashes securely (password-independent)
+ - Verify codes against hashes WITHOUT password
+ - Track used/unused codes
+ - Support password recovery without original password
+ - One-time use enforcement (non-bypassable)
+
+ Attributes:
+ recovery_codes_file: Path to recovery code hashes file
+ """
+
+ # Recovery code format: 4 groups of 4 alphanumeric chars (case-insensitive)
+ # Example: ABCD-EFGH-IJKL-MNOP
+ CODE_CHARS = string.ascii_uppercase + string.digits
+ CODES_PER_GROUP = 4
+ GROUPS_PER_CODE = 4
+ TOTAL_CODES = 10
+
+ # Hash security parameters
+ HASH_ITERATIONS = 100000 # PBKDF2 iterations (high for security)
+ SALT_LENGTH = 32 # 32 bytes = 256 bits (cryptographically secure)
+
+ def __init__(self, recovery_codes_file_path: str):
+ """
+ Initialize the RecoveryCodeManager.
+
+ Args:
+ recovery_codes_file_path: Full path to recovery_codes.json file
+ """
+ self.recovery_codes_file = recovery_codes_file_path
+ print(f"[RecoveryCodeManager] Initialized with codes file: {recovery_codes_file_path}")
+
+ @staticmethod
+ def generate_code() -> str:
+ """
+ Generate a single recovery code in format XXXX-XXXX-XXXX-XXXX.
+
+ Returns:
+ Generated recovery code (uppercase alphanumeric)
+ """
+ code_parts = []
+ for _ in range(RecoveryCodeManager.GROUPS_PER_CODE):
+ part = ''.join(secrets.choice(RecoveryCodeManager.CODE_CHARS)
+ for _ in range(RecoveryCodeManager.CODES_PER_GROUP))
+ code_parts.append(part)
+ return '-'.join(code_parts)
+
+ @staticmethod
+ def generate_codes(count: int = TOTAL_CODES) -> List[str]:
+ """
+ Generate multiple unique recovery codes.
+
+ Args:
+ count: Number of codes to generate (default: 10)
+
+ Returns:
+ List of unique recovery codes
+ """
+ codes = set()
+ while len(codes) < count:
+ codes.add(RecoveryCodeManager.generate_code())
+ return sorted(list(codes))
+
+ @staticmethod
+ def _hash_recovery_code(code: str, salt: bytes) -> bytes:
+ """
+ Hash a recovery code using PBKDF2-HMAC-SHA256.
+
+ SECURITY:
+ - Uses 100,000 iterations (computationally expensive for attackers)
+ - Unique salt per code (prevents rainbow table attacks)
+ - SHA-256 output (256-bit security)
+ - Even with hash + salt, code cannot be reversed
+
+ Args:
+ code: Recovery code to hash (normalized: uppercase, no dashes)
+ salt: Random salt bytes (32 bytes)
+
+ Returns:
+ Hash bytes (32 bytes from SHA-256)
+ """
+ # Normalize code: uppercase, no separators
+ normalized_code = code.upper().replace('-', '').replace(' ', '')
+ code_bytes = normalized_code.encode('utf-8')
+
+ # PBKDF2-HMAC-SHA256 with 100k iterations
+ hash_bytes = hashlib.pbkdf2_hmac(
+ 'sha256',
+ code_bytes,
+ salt,
+ RecoveryCodeManager.HASH_ITERATIONS
+ )
+ return hash_bytes
+
+ @staticmethod
+ def _verify_code_against_hash(code: str, stored_hash: bytes, salt: bytes) -> bool:
+ """
+ Verify a recovery code against its stored hash.
+
+ Args:
+ code: User-entered recovery code
+ stored_hash: Stored hash bytes
+ salt: Salt used for original hash
+
+ Returns:
+ True if code matches hash, False otherwise
+ """
+ computed_hash = RecoveryCodeManager._hash_recovery_code(code, salt)
+ # Constant-time comparison (prevents timing attacks)
+ return secrets.compare_digest(computed_hash, stored_hash)
+
+ def create_recovery_codes(self) -> Tuple[bool, Optional[List[str]]]:
+ """
+ Generate and store new recovery codes using hash-based storage.
+
+ SECURITY:
+ - Codes hashed with PBKDF2-HMAC-SHA256 (100,000 iterations)
+ - Unique 32-byte salt per code
+ - Hashes stored in plain JSON (no password encryption)
+ - Actual codes returned ONCE to display to user
+
+ Returns:
+ Tuple of (success: bool, codes: Optional[List[str]])
+ """
+ try:
+ # Generate 10 unique codes
+ codes = self.generate_codes(self.TOTAL_CODES)
+
+ # Create recovery data with hashes instead of encrypted codes
+ recovery_data = {
+ 'version': '2.0', # Version 2.0 uses hash-based verification
+ 'created_at': datetime.now().isoformat(),
+ 'hash_algorithm': 'PBKDF2-HMAC-SHA256',
+ 'iterations': self.HASH_ITERATIONS,
+ 'codes': []
+ }
+
+ # Hash each code with unique salt
+ for code in codes:
+ # Generate unique random salt (32 bytes = 256 bits)
+ salt = secrets.token_bytes(self.SALT_LENGTH)
+
+ # Hash the code
+ code_hash = self._hash_recovery_code(code, salt)
+
+ # Store hash + salt + metadata (NOT the code itself)
+ recovery_data['codes'].append({
+ 'hash': code_hash.hex(), # Store as hex string
+ 'salt': salt.hex(), # Store as hex string
+ 'used': False,
+ 'used_at': None,
+ 'attempts': 0,
+ 'created_at': datetime.now().isoformat()
+ })
+
+ # Save to file (plain JSON, no encryption needed)
+ # The hashes are useless without the actual codes
+ with open(self.recovery_codes_file, 'w') as f:
+ json.dump(recovery_data, f, indent=2)
+
+ print(f"[RecoveryCodeManager] โ
Created {len(codes)} recovery codes with secure hashes")
+ print(f"[RecoveryCodeManager] Hash algorithm: PBKDF2-HMAC-SHA256 ({self.HASH_ITERATIONS} iterations)")
+ print(f"[RecoveryCodeManager] File now exists: {os.path.exists(self.recovery_codes_file)}")
+ return True, codes
+
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error creating recovery codes: {e}")
+ import traceback
+ traceback.print_exc()
+ return False, None
+
+ def verify_recovery_code(self, code: str) -> Tuple[bool, Optional[str]]:
+ """
+ Verify if a recovery code is valid and unused using hash-based verification.
+
+ SECURITY:
+ - Does NOT require master password
+ - Compares entered code hash against stored hashes
+ - Uses constant-time comparison (prevents timing attacks)
+ - Computationally expensive to brute force (100k iterations per attempt)
+
+ Args:
+ code: Recovery code to verify (will be normalized to uppercase)
+
+ Returns:
+ Tuple of (is_valid: bool, error_message: Optional[str])
+ """
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ return False, "Recovery codes not found"
+
+ # Normalize code (remove dashes/spaces, convert to uppercase)
+ normalized_input = code.upper().replace('-', '').replace(' ', '')
+ if len(normalized_input) != self.GROUPS_PER_CODE * self.CODES_PER_GROUP:
+ return False, "Invalid recovery code format"
+
+ # Load recovery data (plain JSON)
+ with open(self.recovery_codes_file, 'r') as f:
+ recovery_data = json.load(f)
+
+ # Verify code against stored hashes
+ for code_entry in recovery_data.get('codes', []):
+ # Get stored hash and salt
+ stored_hash_hex = code_entry.get('hash')
+ salt_hex = code_entry.get('salt')
+
+ if not stored_hash_hex or not salt_hex:
+ continue
+
+ # Convert from hex
+ stored_hash = bytes.fromhex(stored_hash_hex)
+ salt = bytes.fromhex(salt_hex)
+
+ # Verify code against this hash
+ if self._verify_code_against_hash(normalized_input, stored_hash, salt):
+ # Code matches - check if already used
+ if code_entry.get('used', False):
+ return False, "This recovery code has already been used"
+
+ # Code is valid and unused
+ print("[RecoveryCodeManager] Recovery code verified")
+ return True, None
+
+ # Code not found in any hash
+ return False, "Recovery code not found or incorrect"
+
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error verifying recovery code: {e}")
+ import traceback
+ traceback.print_exc()
+ return False, f"Error verifying code: {str(e)}"
+
+ def consume_recovery_code(self, code: str) -> Tuple[bool, Optional[str]]:
+ """
+ Mark a recovery code as used (one-time consumption).
+
+ SECURITY:
+ - Marks code as used in hash storage file
+ - Uses file locking to prevent race conditions
+ - Cannot be bypassed (hash storage is permanent)
+
+ Args:
+ code: Recovery code to consume
+
+ Returns:
+ Tuple of (success: bool, error_message: Optional[str])
+ """
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ return False, "Recovery codes not found"
+
+ # Normalize code
+ normalized_input = code.upper().replace('-', '').replace(' ', '')
+
+ # Load current data
+ with open(self.recovery_codes_file, 'r') as f:
+ recovery_data = json.load(f)
+
+ # Find and mark code as used
+ code_found = False
+ for code_entry in recovery_data.get('codes', []):
+ stored_hash_hex = code_entry.get('hash')
+ salt_hex = code_entry.get('salt')
+
+ if not stored_hash_hex or not salt_hex:
+ continue
+
+ # Convert from hex
+ stored_hash = bytes.fromhex(stored_hash_hex)
+ salt = bytes.fromhex(salt_hex)
+
+ # Check if this is the matching code
+ if self._verify_code_against_hash(normalized_input, stored_hash, salt):
+ # Mark as used
+ code_entry['used'] = True
+ code_entry['used_at'] = datetime.now().isoformat()
+ code_found = True
+ break
+
+ if not code_found:
+ return False, "Recovery code not found"
+
+ # Save updated data
+ with open(self.recovery_codes_file, 'w') as f:
+ json.dump(recovery_data, f, indent=2)
+
+ print("[RecoveryCodeManager] Recovery code marked as used")
+ return True, None
+
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error consuming recovery code: {e}")
+ import traceback
+ traceback.print_exc()
+ return False, f"Error consuming code: {str(e)}"
+
+ def delete_recovery_codes(self) -> bool:
+ """
+ Delete recovery codes file (used during password reset).
+
+ This is part of cleanup process when resetting password via recovery code.
+ New codes will be generated with the new password.
+
+ Returns:
+ True if deleted successfully, False otherwise
+ """
+ try:
+ if os.path.exists(self.recovery_codes_file):
+ os.remove(self.recovery_codes_file)
+ print("[RecoveryCodeManager] Recovery codes deleted")
+ return True
+ return True # Already deleted
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error deleting recovery codes: {e}")
+ return False
+
+ def has_recovery_codes(self) -> bool:
+ """
+ Check if recovery codes have been set.
+
+ Returns:
+ True if recovery codes file exists, False otherwise
+ """
+ return os.path.exists(self.recovery_codes_file)
+
+ def get_remaining_codes_count(self) -> Tuple[bool, Optional[int]]:
+ """
+ Get count of unused recovery codes.
+
+ Returns:
+ Tuple of (success: bool, count: Optional[int])
+ """
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ return False, None
+
+ # Load plain JSON
+ with open(self.recovery_codes_file, 'r') as f:
+ recovery_data = json.load(f)
+
+ # Count unused codes
+ unused_count = sum(
+ 1 for code_entry in recovery_data.get('codes', [])
+ if not code_entry.get('used', False)
+ )
+
+ return True, unused_count
+
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error counting recovery codes: {e}")
+ return False, None
+
+ def list_recovery_codes(self) -> Tuple[bool, Optional[List[Dict]]]:
+ """
+ List recovery code metadata (NOT the actual codes - they're hashed).
+
+ SECURITY:
+ - Actual codes are NEVER stored, only hashes
+ - Returns metadata: used status, timestamps
+
+ NOTE: Cannot return actual codes because they're not stored.
+
+ Returns:
+ Tuple of (success: bool, metadata_list: Optional[List[Dict]])
+ """
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ return False, None
+
+ # Load plain JSON
+ with open(self.recovery_codes_file, 'r') as f:
+ recovery_data = json.load(f)
+
+ # Return metadata only
+ codes_metadata = []
+ for entry in recovery_data.get('codes', []):
+ codes_metadata.append({
+ 'code': '[HASHED - NOT RECOVERABLE]', # Cannot show actual codes
+ 'used': entry.get('used', False),
+ 'used_at': entry.get('used_at'),
+ 'created_at': entry.get('created_at')
+ })
+
+ return True, codes_metadata
+
+ except Exception as e:
+ print(f"[RecoveryCodeManager] โ Error listing recovery codes: {e}")
+ return False, None
+
diff --git a/core/unified_monitor.py b/core/unified_monitor.py
index 51d189d..ac49f5f 100644
--- a/core/unified_monitor.py
+++ b/core/unified_monitor.py
@@ -122,8 +122,8 @@ def _prepare_app_monitors(self, applications: List[Dict[str, str]]) -> List[Dict
# CRITICAL: Generic commands that should never be monitored
# These are too common and will match system processes
- dangerous_paths = {
- 'env', 'sh', 'bash', 'zsh', 'python', 'python3',
+ dangerous_process_names = {
+ 'sh', 'bash', 'zsh', 'python', 'python3',
'node', 'java', 'perl', 'ruby', 'php',
'systemd', 'init', 'dbus', 'gdm', 'lightdm',
'x11', 'xorg', 'wayland', 'gnome', 'kde', 'plasma'
@@ -136,12 +136,22 @@ def _prepare_app_monitors(self, applications: List[Dict[str, str]]) -> List[Dict
# Clean the path (remove quotes that might be in config)
app_path = app_path.strip().strip('"').strip("'")
- # Cache process name
- process_name = os.path.basename(app_path) if app_path else app_name
+ # Handle "env" path specially - it means "find in PATH"
+ # We should use app_name as process_name for these apps
+ if app_path.lower() == "env":
+ process_name = app_name.lower()
+ # Convert app name to likely process name (e.g., "Android Studio" -> "studio")
+ # Take the last word as process name (more likely to match)
+ words = process_name.split()
+ if len(words) > 1:
+ process_name = words[-1] # e.g., "studio" from "android studio"
+ else:
+ # Cache process name from path
+ process_name = os.path.basename(app_path) if app_path else app_name
- # CRITICAL: Skip dangerous/generic paths
- if process_name.lower() in dangerous_paths or app_path.lower() in dangerous_paths:
- print(f" [WARNING] Skipping app '{app_name}' - path '{app_path}' is too generic and unsafe")
+ # CRITICAL: Skip dangerous/generic process names (but NOT "env" path marker)
+ if process_name.lower() in dangerous_process_names:
+ print(f" [WARNING] Skipping app '{app_name}' - process name '{process_name}' is too generic and unsafe")
print(f" This prevents accidentally killing system processes!")
continue
@@ -249,7 +259,14 @@ def _find_app_processes(
break # Found chrome processes, no need to continue
# For non-Chrome apps: STRICT matching to avoid false positives
- # CRITICAL: Don't match if app_path is too short (< 4 chars) - it's too generic
+ # Special handling for "env" path apps - match by process_name only
+ elif app_path and app_path.lower() == "env":
+ # "env" means find in PATH - match by process_name using word boundaries
+ import re
+ pattern = r'\b' + re.escape(process_name) + r'\b'
+ if re.search(pattern, cmdline_str):
+ app_processes.append(proc)
+ # For apps with real paths: STRICT path matching
elif app_path and len(app_path) >= 4:
# Match full path (more reliable than substring matching)
if app_path in cmdline_str:
@@ -257,7 +274,6 @@ def _find_app_processes(
# Fallback: match process_name only if it's specific enough (>= 5 chars)
elif len(process_name) >= 5 and process_name in cmdline_str:
# Additional check: ensure it's not a substring of another word
- # e.g., "env" shouldn't match "gnome-session-binary"
import re
# Use word boundary matching
pattern = r'\b' + re.escape(process_name) + r'\b'
diff --git a/tests/test_recovery_persistence.py b/tests/test_recovery_persistence.py
new file mode 100644
index 0000000..c1c4462
--- /dev/null
+++ b/tests/test_recovery_persistence.py
@@ -0,0 +1,576 @@
+#!/usr/bin/env python3
+"""
+Recovery Code Persistence Test Suite
+Tests the new recovery code persistence logic after password reset.
+
+Test Cases:
+1. Recovery codes persist after password reset
+2. Used code is marked as consumed
+3. Remaining codes are still valid after one is used
+4. has_recovery_codes() returns correct status
+5. Error messages are specific (used vs invalid vs not found)
+6. Multiple password resets with different codes
+7. Code file not deleted during password reset
+"""
+
+import os
+import sys
+import json
+import tempfile
+import shutil
+from pathlib import Path
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from core.crypto_manager import CryptoManager
+from core.recovery_manager import RecoveryCodeManager
+from core.password_manager import PasswordManager
+
+
+class RecoveryCodePersistenceTest:
+ """Test suite for recovery code persistence after password reset"""
+
+ def __init__(self):
+ self.test_dir = tempfile.mkdtemp(prefix="fadcrypt_persistence_test_")
+ self.crypto = CryptoManager()
+ self.password = "TestPassword123!@#"
+ self.new_password1 = "NewPassword456$%^"
+ self.new_password2 = "AnotherPassword789&*()"
+ self.recovery_codes_file = os.path.join(self.test_dir, "recovery_codes.json")
+ self.password_file = os.path.join(self.test_dir, "encrypted_password.bin")
+ self.tests_passed = 0
+ self.tests_failed = 0
+ print(f"๐งช Recovery Code Persistence Test Suite")
+ print(f"๐ Test directory: {self.test_dir}\n")
+
+ def cleanup(self):
+ """Clean up test directory"""
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ print(f"๐๏ธ Cleaned up test directory")
+
+ def log_pass(self, test_name):
+ """Log successful test"""
+ self.tests_passed += 1
+ print(f"โ
PASS: {test_name}")
+
+ def log_fail(self, test_name, reason):
+ """Log failed test"""
+ self.tests_failed += 1
+ print(f"โ FAIL: {test_name}")
+ print(f" Reason: {reason}")
+
+ def test_1_setup_password_and_codes(self):
+ """Test 1: Setup - Create password and recovery codes"""
+ print("\n" + "="*70)
+ print("Test 1: Setup Password and Recovery Codes")
+ print("="*70)
+
+ try:
+ # Create password manager with full file paths
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Create master password
+ if not pwd_mgr.create_password(self.password):
+ self.log_fail("Setup password creation", "Failed to create password")
+ return None
+
+ # Generate recovery codes
+ success, codes = pwd_mgr.create_recovery_codes()
+ if not success or not codes:
+ self.log_fail("Setup recovery code generation", "Failed to generate codes")
+ return None
+
+ print(f"๐ Generated {len(codes)} recovery codes")
+
+ # Verify all codes are valid
+ for i, code in enumerate(codes):
+ is_valid, error = pwd_mgr.verify_recovery_code(code)
+ if not is_valid:
+ self.log_fail("Setup code verification", f"Code {i+1} is invalid: {error}")
+ return None
+
+ self.log_pass("Setup - Password and recovery codes created")
+ return codes
+
+ except Exception as e:
+ self.log_fail("Setup", f"Exception: {e}")
+ return None
+
+ def test_2_recovery_file_exists(self):
+ """Test 2: Verify recovery codes file exists"""
+ print("\n" + "="*70)
+ print("Test 2: Recovery Codes File Exists")
+ print("="*70)
+
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ self.log_fail("File existence check", "Recovery codes file not found")
+ return False
+
+ # Check file contents
+ with open(self.recovery_codes_file, 'r') as f:
+ data = json.load(f)
+
+ if 'version' not in data or data['version'] != '2.0':
+ self.log_fail("File format check", f"Invalid version: {data.get('version')}")
+ return False
+
+ if 'codes' not in data:
+ self.log_fail("File format check", "No 'codes' key in file")
+ return False
+
+ codes_count = len(data['codes'])
+ print(f"๐ File contains {codes_count} codes")
+
+ self.log_pass("Recovery codes file exists with correct format")
+ return True
+
+ except Exception as e:
+ self.log_fail("File existence check", f"Exception: {e}")
+ return False
+
+ def test_3_password_reset_with_code(self, codes):
+ """Test 3: Reset password using first recovery code"""
+ print("\n" + "="*70)
+ print("Test 3: Password Reset with Recovery Code")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Use first code to reset password
+ first_code = codes[0]
+ print(f"๐ Using code: {first_code[:8]}...")
+
+ success, error = pwd_mgr.recover_password_with_code(first_code, self.new_password1)
+ if not success:
+ self.log_fail("Password reset", f"Reset failed: {error}")
+ return False
+
+ # Verify new password works
+ if not pwd_mgr.verify_password(self.new_password1):
+ self.log_fail("Password verification", "New password doesn't work")
+ return False
+
+ self.log_pass("Password reset with recovery code successful")
+ return True
+
+ except Exception as e:
+ self.log_fail("Password reset", f"Exception: {e}")
+ return False
+
+ def test_4_recovery_file_still_exists(self):
+ """Test 4: CRITICAL - Verify recovery codes file still exists after reset"""
+ print("\n" + "="*70)
+ print("Test 4: Recovery Codes File Persists After Reset")
+ print("="*70)
+
+ try:
+ if not os.path.exists(self.recovery_codes_file):
+ self.log_fail("File persistence check", "โ Recovery codes file was DELETED after password reset!")
+ return False
+
+ print("โ
Recovery codes file still exists")
+
+ # Check file contents
+ with open(self.recovery_codes_file, 'r') as f:
+ data = json.load(f)
+
+ codes_count = len(data['codes'])
+ print(f"๐ File still contains {codes_count} codes")
+
+ self.log_pass("Recovery codes file persisted after password reset")
+ return True
+
+ except Exception as e:
+ self.log_fail("File persistence check", f"Exception: {e}")
+ return False
+
+ def test_5_used_code_marked_consumed(self, codes):
+ """Test 5: Verify first code is marked as used"""
+ print("\n" + "="*70)
+ print("Test 5: Used Code Marked as Consumed")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Try to verify the used code - should fail
+ first_code = codes[0]
+ is_valid, error = pwd_mgr.verify_recovery_code(first_code)
+
+ if is_valid:
+ self.log_fail("Used code check", "Used code still shows as valid!")
+ return False
+
+ # Check error message
+ if not error or "already been used" not in error.lower():
+ self.log_fail("Error message check", f"Wrong error for used code: {error}")
+ return False
+
+ print(f"โ
Used code correctly marked as consumed")
+ print(f"๐ Error message: {error}")
+
+ self.log_pass("Used code marked as consumed with correct error message")
+ return True
+
+ except Exception as e:
+ self.log_fail("Used code check", f"Exception: {e}")
+ return False
+
+ def test_6_remaining_codes_valid(self, codes):
+ """Test 6: CRITICAL - Verify remaining codes are still valid"""
+ print("\n" + "="*70)
+ print("Test 6: Remaining Codes Still Valid")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Test codes 2-5 (index 1-4)
+ remaining_codes = codes[1:]
+ valid_count = 0
+
+ for i, code in enumerate(remaining_codes):
+ is_valid, error = pwd_mgr.verify_recovery_code(code)
+ if is_valid:
+ valid_count += 1
+ print(f"โ
Code {i+2}: Valid")
+ else:
+ print(f"โ Code {i+2}: INVALID - {error}")
+ self.log_fail("Remaining codes check", f"Code {i+2} is invalid: {error}")
+ return False
+
+ print(f"\nโ
All {valid_count} remaining codes are valid")
+ self.log_pass(f"All {valid_count} remaining codes valid after one was used")
+ return True
+
+ except Exception as e:
+ self.log_fail("Remaining codes check", f"Exception: {e}")
+ return False
+
+ def test_7_has_recovery_codes_accuracy(self):
+ """Test 7: Verify has_recovery_codes() returns correct status"""
+ print("\n" + "="*70)
+ print("Test 7: has_recovery_codes() Accuracy")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Should return True since codes still exist
+ has_codes = pwd_mgr.has_recovery_codes()
+ if not has_codes:
+ self.log_fail("has_recovery_codes check", "Returns False but codes exist!")
+ return False
+
+ print("โ
has_recovery_codes() correctly returns True")
+
+ # Get remaining count
+ success, count = pwd_mgr.get_remaining_recovery_codes_count()
+ if success:
+ print(f"๐ Remaining unused codes: {count}")
+
+ self.log_pass("has_recovery_codes() returns correct status")
+ return True
+
+ except Exception as e:
+ self.log_fail("has_recovery_codes check", f"Exception: {e}")
+ return False
+
+ def test_8_second_password_reset(self, codes):
+ """Test 8: Reset password again with second code"""
+ print("\n" + "="*70)
+ print("Test 8: Second Password Reset with Different Code")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Use second code to reset password again
+ second_code = codes[1]
+ print(f"๐ Using code: {second_code[:8]}...")
+
+ success, error = pwd_mgr.recover_password_with_code(second_code, self.new_password2)
+ if not success:
+ self.log_fail("Second password reset", f"Reset failed: {error}")
+ return False
+
+ # Verify new password works
+ if not pwd_mgr.verify_password(self.new_password2):
+ self.log_fail("Password verification", "New password doesn't work")
+ return False
+
+ self.log_pass("Second password reset successful")
+ return True
+
+ except Exception as e:
+ self.log_fail("Second password reset", f"Exception: {e}")
+ return False
+
+ def test_9_both_codes_marked_used(self, codes):
+ """Test 9: Verify both used codes are marked as consumed"""
+ print("\n" + "="*70)
+ print("Test 9: Both Used Codes Marked as Consumed")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Check first code (used in test 3)
+ is_valid1, error1 = pwd_mgr.verify_recovery_code(codes[0])
+ if is_valid1:
+ self.log_fail("First code status", "First code still shows as valid!")
+ return False
+
+ # Check second code (used in test 8)
+ is_valid2, error2 = pwd_mgr.verify_recovery_code(codes[1])
+ if is_valid2:
+ self.log_fail("Second code status", "Second code still shows as valid!")
+ return False
+
+ print("โ
Both used codes correctly marked as consumed")
+ print(f"๐ Code 1 error: {error1}")
+ print(f"๐ Code 2 error: {error2}")
+
+ self.log_pass("Both used codes correctly marked as consumed")
+ return True
+
+ except Exception as e:
+ self.log_fail("Both codes status", f"Exception: {e}")
+ return False
+
+ def test_10_remaining_codes_still_valid(self, codes):
+ """Test 10: Verify codes 3-5 are still valid after two password resets"""
+ print("\n" + "="*70)
+ print("Test 10: Remaining Codes Valid After Two Resets")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Test codes 3-5 (index 2-4)
+ remaining_codes = codes[2:]
+ valid_count = 0
+
+ for i, code in enumerate(remaining_codes):
+ is_valid, error = pwd_mgr.verify_recovery_code(code)
+ if is_valid:
+ valid_count += 1
+ print(f"โ
Code {i+3}: Valid")
+ else:
+ print(f"โ Code {i+3}: INVALID - {error}")
+ self.log_fail("Remaining codes check", f"Code {i+3} is invalid: {error}")
+ return False
+
+ print(f"\nโ
All {valid_count} remaining codes are valid after TWO password resets")
+ self.log_pass(f"All {valid_count} codes valid after two password resets")
+ return True
+
+ except Exception as e:
+ self.log_fail("Remaining codes check", f"Exception: {e}")
+ return False
+
+ def test_11_error_message_specificity(self, codes):
+ """Test 11: Verify error messages are specific and accurate"""
+ print("\n" + "="*70)
+ print("Test 11: Error Message Specificity")
+ print("="*70)
+
+ try:
+ pwd_mgr = PasswordManager(
+ self.password_file,
+ recovery_codes_file_path=self.recovery_codes_file
+ )
+
+ # Test used code error
+ _, used_error = pwd_mgr.verify_recovery_code(codes[0])
+ if not used_error or "already been used" not in used_error.lower():
+ self.log_fail("Used code error message", f"Wrong message: {used_error}")
+ return False
+ print(f"โ
Used code error: {used_error}")
+
+ # Test invalid code error (correct format but wrong code)
+ _, invalid_error = pwd_mgr.verify_recovery_code("1234-5678-9ABC-DEFG")
+ if not invalid_error or ("not found" not in invalid_error.lower() and "incorrect" not in invalid_error.lower()):
+ self.log_fail("Invalid code error message", f"Wrong message: {invalid_error}")
+ return False
+ print(f"โ
Invalid code error: {invalid_error}")
+
+ # Test wrong format error
+ _, format_error = pwd_mgr.verify_recovery_code("ABC")
+ if not format_error or "format" not in format_error.lower():
+ self.log_fail("Format error message", f"Wrong message: {format_error}")
+ return False
+ print(f"โ
Format error: {format_error}")
+
+ self.log_pass("All error messages are specific and accurate")
+ return True
+
+ except Exception as e:
+ self.log_fail("Error message check", f"Exception: {e}")
+ return False
+
+ def test_12_file_integrity_check(self):
+ """Test 12: Verify recovery codes file structure integrity"""
+ print("\n" + "="*70)
+ print("Test 12: File Structure Integrity")
+ print("="*70)
+
+ try:
+ with open(self.recovery_codes_file, 'r') as f:
+ data = json.load(f)
+
+ # Check version
+ if data.get('version') != '2.0':
+ self.log_fail("File integrity", f"Wrong version: {data.get('version')}")
+ return False
+
+ # Check codes array
+ codes = data.get('codes', [])
+ if not codes:
+ self.log_fail("File integrity", "No codes in file")
+ return False
+
+ print(f"๐ File contains {len(codes)} codes")
+
+ # Check each code entry structure
+ for i, code_entry in enumerate(codes):
+ required_keys = ['hash', 'salt', 'used']
+ for key in required_keys:
+ if key not in code_entry:
+ self.log_fail("File integrity", f"Code {i+1} missing '{key}' field")
+ return False
+
+ # Check if used flag is boolean
+ if not isinstance(code_entry['used'], bool):
+ self.log_fail("File integrity", f"Code {i+1} 'used' is not boolean")
+ return False
+
+ # Count used vs unused
+ used_count = sum(1 for c in codes if c.get('used'))
+ unused_count = len(codes) - used_count
+
+ print(f"๐ Used codes: {used_count}")
+ print(f"๐ Unused codes: {unused_count}")
+
+ if used_count != 2:
+ self.log_fail("File integrity", f"Expected 2 used codes, got {used_count}")
+ return False
+
+ self.log_pass("File structure integrity verified")
+ return True
+
+ except Exception as e:
+ self.log_fail("File integrity check", f"Exception: {e}")
+ return False
+
+ def run_all_tests(self):
+ """Run all tests in sequence"""
+ print("\n" + "="*70)
+ print("๐ STARTING RECOVERY CODE PERSISTENCE TEST SUITE")
+ print("="*70)
+
+ # Test 1: Setup
+ codes = self.test_1_setup_password_and_codes()
+ if not codes:
+ print("\nโ Setup failed - cannot continue")
+ return
+
+ # Test 2: File exists
+ if not self.test_2_recovery_file_exists():
+ print("\nโ File existence check failed - cannot continue")
+ return
+
+ # Test 3: Password reset
+ if not self.test_3_password_reset_with_code(codes):
+ print("\nโ Password reset failed - cannot continue")
+ return
+
+ # Test 4: CRITICAL - File still exists
+ if not self.test_4_recovery_file_still_exists():
+ print("\nโ CRITICAL FAILURE: File was deleted!")
+ return
+
+ # Test 5: Used code marked
+ self.test_5_used_code_marked_consumed(codes)
+
+ # Test 6: CRITICAL - Remaining codes valid
+ if not self.test_6_remaining_codes_valid(codes):
+ print("\nโ CRITICAL FAILURE: Remaining codes invalid!")
+ return
+
+ # Test 7: has_recovery_codes() accuracy
+ self.test_7_has_recovery_codes_accuracy()
+
+ # Test 8: Second reset
+ if not self.test_8_second_password_reset(codes):
+ print("\nโ Second password reset failed")
+ return
+
+ # Test 9: Both codes marked used
+ self.test_9_both_codes_marked_used(codes)
+
+ # Test 10: Remaining codes still valid
+ self.test_10_remaining_codes_still_valid(codes)
+
+ # Test 11: Error message specificity
+ self.test_11_error_message_specificity(codes)
+
+ # Test 12: File integrity
+ self.test_12_file_integrity_check()
+
+ # Print summary
+ print("\n" + "="*70)
+ print("๐ TEST SUMMARY")
+ print("="*70)
+ print(f"โ
Tests passed: {self.tests_passed}")
+ print(f"โ Tests failed: {self.tests_failed}")
+ print(f"๐ Success rate: {self.tests_passed}/{self.tests_passed + self.tests_failed} " +
+ f"({100 * self.tests_passed / (self.tests_passed + self.tests_failed):.1f}%)")
+
+ if self.tests_failed == 0:
+ print("\n๐ ALL TESTS PASSED! Recovery code persistence is working correctly.")
+ else:
+ print(f"\nโ ๏ธ {self.tests_failed} test(s) failed. Please review the failures above.")
+
+ print("="*70)
+
+
+def main():
+ """Main test runner"""
+ test_suite = RecoveryCodePersistenceTest()
+ try:
+ test_suite.run_all_tests()
+ finally:
+ test_suite.cleanup()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_recovery_security.py b/tests/test_recovery_security.py
new file mode 100644
index 0000000..8d16185
--- /dev/null
+++ b/tests/test_recovery_security.py
@@ -0,0 +1,389 @@
+#!/usr/bin/env python3
+"""
+Recovery Code Security Test Suite
+Tests the security of the recovery code system to ensure it's non-bypassable
+without valid backup codes.
+
+Test Cases:
+1. Code generation uniqueness
+2. Code encryption and decryption
+3. One-time use enforcement
+4. Tamper detection
+5. Brute force resistance
+6. File deletion/corruption handling
+"""
+
+import os
+import sys
+import json
+import tempfile
+import shutil
+from pathlib import Path
+
+# Add parent directory to path
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from core.crypto_manager import CryptoManager
+from core.recovery_manager import RecoveryCodeManager
+from core.password_manager import PasswordManager
+
+
+class RecoveryCodeSecurityTest:
+ """Security test suite for recovery codes"""
+
+ def __init__(self):
+ self.test_dir = tempfile.mkdtemp(prefix="fadcrypt_test_")
+ self.crypto = CryptoManager()
+ self.password = "TestPassword123!@#"
+ self.new_password = "NewPassword456$%^"
+ self.recovery_codes_file = os.path.join(self.test_dir, "recovery_codes.json")
+ self.password_file = os.path.join(self.test_dir, "encrypted_password.bin")
+ self.tests_passed = 0
+ self.tests_failed = 0
+ print(f"๐งช Recovery Code Security Test Suite")
+ print(f"๐ Test directory: {self.test_dir}\n")
+
+ def cleanup(self):
+ """Clean up test directory"""
+ if os.path.exists(self.test_dir):
+ shutil.rmtree(self.test_dir)
+ print(f"๐๏ธ Cleaned up test directory")
+
+ def assert_true(self, condition: bool, message: str):
+ """Assert condition is true"""
+ if condition:
+ print(f"โ
{message}")
+ self.tests_passed += 1
+ else:
+ print(f"โ {message}")
+ self.tests_failed += 1
+
+ def test_code_generation(self):
+ """Test 1: Recovery codes are generated uniquely"""
+ print("\n" + "="*60)
+ print("TEST 1: Code Generation and Uniqueness")
+ print("="*60)
+
+ manager = RecoveryCodeManager(self.recovery_codes_file, self.crypto)
+
+ # Test single code generation
+ code1 = RecoveryCodeManager.generate_code()
+ code2 = RecoveryCodeManager.generate_code()
+ self.assert_true(code1 != code2, "Each generated code is unique")
+
+ # Test code format
+ import re
+ pattern = r'^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
+ self.assert_true(bool(re.match(pattern, code1)), f"Code format is valid: {code1}")
+
+ # Test batch generation
+ codes = RecoveryCodeManager.generate_codes(10)
+ self.assert_true(len(codes) == 10, f"Generated exactly 10 codes")
+ self.assert_true(len(set(codes)) == 10, "All 10 codes are unique")
+
+ print(f"\n๐ Sample codes:\n")
+ for i, code in enumerate(codes[:3], 1):
+ print(f" {i}. {code}")
+ print(f" ... and 7 more")
+
+ def test_encryption_integrity(self):
+ """Test 2: Recovery codes are encrypted and cannot be read without password"""
+ print("\n" + "="*60)
+ print("TEST 2: Encryption Integrity")
+ print("="*60)
+
+ manager = RecoveryCodeManager(self.recovery_codes_file, self.crypto)
+
+ # Create recovery codes
+ success, codes = manager.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created successfully")
+ self.assert_true(codes is not None and len(codes) == 10, "Generated 10 recovery codes")
+
+ # Verify file exists and is encrypted
+ self.assert_true(
+ os.path.exists(self.recovery_codes_file),
+ "Recovery codes file created"
+ )
+
+ # Try to read raw file - should be binary/unreadable
+ with open(self.recovery_codes_file, 'rb') as f:
+ raw_data = f.read()
+
+ self.assert_true(
+ len(raw_data) > 0,
+ "Recovery codes file contains data"
+ )
+
+ # Try to decode as JSON (should fail)
+ try:
+ json.loads(raw_data)
+ self.assert_true(False, "Raw file is NOT valid JSON (encrypted)")
+ except:
+ self.assert_true(True, "Raw file is NOT valid JSON (encrypted)")
+
+ # Verify decryption with correct password works
+ data = self.crypto.decrypt_data(
+ password=self.password.encode('utf-8'),
+ file_path=self.recovery_codes_file
+ )
+ self.assert_true(data is not None, "Decryption with correct password succeeds")
+
+ # Verify decryption with wrong password fails
+ data_wrong = self.crypto.decrypt_data(
+ password="WrongPassword".encode('utf-8'),
+ file_path=self.recovery_codes_file,
+ suppress_errors=True
+ )
+ self.assert_true(data_wrong is None, "Decryption with wrong password fails")
+
+ def test_one_time_use_enforcement(self):
+ """Test 3: Recovery codes can only be used ONCE (non-bypassable)"""
+ print("\n" + "="*60)
+ print("TEST 3: One-Time Use Enforcement")
+ print("="*60)
+
+ manager = RecoveryCodeManager(self.recovery_codes_file, self.crypto)
+
+ # Create recovery codes
+ success, codes = manager.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created")
+
+ if not codes:
+ print("โ ๏ธ Skipping - codes were not generated")
+ return
+
+ test_code = codes[0]
+ print(f"\n๐ Testing with code: {test_code}")
+
+ # First verification should succeed
+ is_valid, error = manager.verify_recovery_code(self.password, test_code)
+ self.assert_true(is_valid, "Code verification succeeds on first try")
+ self.assert_true(error is None, "No error on valid code")
+
+ # Consume the code
+ success, error = manager.consume_recovery_code(self.password, test_code)
+ self.assert_true(success, "Code consumed successfully")
+
+ # Second verification should FAIL
+ is_valid2, error2 = manager.verify_recovery_code(self.password, test_code)
+ self.assert_true(
+ not is_valid2,
+ "Code verification FAILS on second try (already used)"
+ )
+ self.assert_true(
+ "already been used" in (error2 or "").lower(),
+ "Error message indicates code was used"
+ )
+
+ # Other codes should still work
+ if len(codes) > 1:
+ test_code2 = codes[1]
+ is_valid3, error3 = manager.verify_recovery_code(self.password, test_code2)
+ self.assert_true(is_valid3, "Other codes still work after one is consumed")
+ else:
+ self.assert_true(True, "Other codes would work (only 1 code in test)")
+
+ def test_tamper_detection(self):
+ """Test 4: Tampering with encrypted file is detected"""
+ print("\n" + "="*60)
+ print("TEST 4: Tamper Detection")
+ print("="*60)
+
+ manager = RecoveryCodeManager(self.recovery_codes_file, self.crypto)
+
+ # Create recovery codes
+ success, codes = manager.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created")
+
+ # Read original file
+ with open(self.recovery_codes_file, 'rb') as f:
+ original_data = f.read()
+
+ # Tamper with file (flip a byte in the middle)
+ tampered_data = bytearray(original_data)
+ if len(tampered_data) > 50:
+ tampered_data[50] ^= 0xFF # Flip bits
+
+ with open(self.recovery_codes_file, 'wb') as f:
+ f.write(tampered_data)
+
+ # Try to decrypt tampered file
+ data_tampered = self.crypto.decrypt_data(
+ password=self.password.encode('utf-8'),
+ file_path=self.recovery_codes_file,
+ suppress_errors=True
+ )
+
+ self.assert_true(
+ data_tampered is None,
+ "Tampered file cannot be decrypted (tampering detected)"
+ )
+
+ def test_brute_force_resistance(self):
+ """Test 5: Brute force attacks are infeasible"""
+ print("\n" + "="*60)
+ print("TEST 5: Brute Force Resistance")
+ print("="*60)
+
+ manager = RecoveryCodeManager(self.recovery_codes_file, self.crypto)
+ success, codes = manager.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created")
+
+ # Try to guess codes
+ import string
+ code_chars = string.ascii_uppercase + string.digits
+
+ # Generate some fake codes
+ fake_codes = [
+ "AAAA-AAAA-AAAA-AAAA",
+ "0000-0000-0000-0000",
+ "1111-1111-1111-1111",
+ "ZZZZ-ZZZZ-ZZZZ-ZZZZ",
+ ]
+
+ # Try to verify fake codes - all should fail
+ all_failed = True
+ for fake_code in fake_codes:
+ is_valid, _ = manager.verify_recovery_code(self.password, fake_code)
+ if is_valid:
+ all_failed = False
+
+ self.assert_true(all_failed, "All guessed codes fail verification")
+
+ # Calculate code space
+ char_count = len(code_chars)
+ code_length = 16 # 4 groups of 4
+ total_combinations = char_count ** code_length
+ print(f"\n๐ Code Space Analysis:")
+ print(f" - Character set: {char_count} (A-Z, 0-9)")
+ print(f" - Effective length: {code_length}")
+ print(f" - Total combinations: {total_combinations:,}")
+ print(f" - At 1 billion guesses/sec: {total_combinations / 1_000_000_000 / 31_536_000:.0f} years")
+
+ def test_file_deletion_recovery(self):
+ """Test 6: Deleted password file cannot be recovered without recovery code"""
+ print("\n" + "="*60)
+ print("TEST 6: File Deletion & Recovery Process")
+ print("="*60)
+
+ password_mgr = PasswordManager(
+ self.password_file,
+ self.crypto,
+ self.recovery_codes_file
+ )
+
+ # Create password and recovery codes
+ success = password_mgr.create_password(self.password)
+ self.assert_true(success, "Master password created")
+
+ success, codes = password_mgr.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created")
+ self.assert_true(codes is not None, "Recovery codes list returned")
+
+ # Verify password works
+ is_valid = password_mgr.verify_password(self.password)
+ self.assert_true(is_valid, "Password verification works")
+
+ # Files should exist
+ self.assert_true(
+ os.path.exists(self.password_file),
+ "Password file exists"
+ )
+ self.assert_true(
+ os.path.exists(self.recovery_codes_file),
+ "Recovery codes file exists"
+ )
+
+ # Delete password file (simulating forgot password)
+ os.remove(self.password_file)
+ self.assert_true(
+ not os.path.exists(self.password_file),
+ "Password file deleted"
+ )
+
+ # Cannot verify old password
+ is_valid2 = password_mgr.verify_password(self.password)
+ self.assert_true(not is_valid2, "Old password cannot be verified (file deleted)")
+
+ print(f"\nโ
Password recovery process verified")
+ print(f" - Old password file: DELETED โ")
+ print(f" - Recovery codes: INTACT โ
")
+ print(f" - New password setup: POSSIBLE โ
")
+
+ def test_password_manager_integration(self):
+ """Test 7: PasswordManager correctly handles recovery codes"""
+ print("\n" + "="*60)
+ print("TEST 7: PasswordManager Integration")
+ print("="*60)
+
+ password_mgr = PasswordManager(
+ self.password_file,
+ self.crypto,
+ self.recovery_codes_file
+ )
+
+ # Create password
+ success = password_mgr.create_password(self.password)
+ self.assert_true(success, "Password created via PasswordManager")
+
+ # Create recovery codes
+ success, codes = password_mgr.create_recovery_codes(self.password)
+ self.assert_true(success, "Recovery codes created via PasswordManager")
+ self.assert_true(codes is not None and len(codes) == 10, "10 codes generated")
+
+ if not codes:
+ print("โ ๏ธ Skipping - codes were not generated")
+ return
+
+ # Check codes exist
+ self.assert_true(
+ password_mgr.has_recovery_codes(),
+ "PasswordManager reports recovery codes exist"
+ )
+
+ # Verify code
+ is_valid, error = password_mgr.verify_recovery_code(self.password, codes[0])
+ self.assert_true(is_valid, "PasswordManager can verify recovery code")
+
+ # Get remaining count
+ success, count = password_mgr.get_remaining_recovery_codes_count(self.password)
+ self.assert_true(success and count == 10, f"All 10 codes available ({count})")
+
+ def run_all_tests(self):
+ """Run all security tests"""
+ try:
+ self.test_code_generation()
+ self.test_encryption_integrity()
+ self.test_one_time_use_enforcement()
+ self.test_tamper_detection()
+ self.test_brute_force_resistance()
+ self.test_file_deletion_recovery()
+ self.test_password_manager_integration()
+
+ # Summary
+ total = self.tests_passed + self.tests_failed
+ print("\n" + "="*60)
+ print("๐ TEST SUMMARY")
+ print("="*60)
+ print(f"โ
Passed: {self.tests_passed}/{total}")
+ print(f"โ Failed: {self.tests_failed}/{total}")
+ print(f"๐ Success Rate: {self.tests_passed/total*100:.1f}%")
+
+ if self.tests_failed == 0:
+ print("\n๐ ALL SECURITY TESTS PASSED!")
+ print("โ Recovery code system is NON-BYPASSABLE")
+ print("โ One-time use is enforced")
+ print("โ Encryption is tamper-proof")
+ return 0
+ else:
+ print(f"\nโ ๏ธ {self.tests_failed} test(s) failed!")
+ return 1
+
+ finally:
+ self.cleanup()
+
+
+if __name__ == "__main__":
+ tester = RecoveryCodeSecurityTest()
+ exit_code = tester.run_all_tests()
+ sys.exit(exit_code)
diff --git a/ui/base/main_window_base.py b/ui/base/main_window_base.py
index 63d7d5e..e17901f 100644
--- a/ui/base/main_window_base.py
+++ b/ui/base/main_window_base.py
@@ -26,6 +26,7 @@
from core.application_manager import ApplicationManager
from core.activity_manager import ActivityManager
from core.statistics_manager import StatisticsManager
+from core.file_protection import get_file_protection_manager
# Import version info
from version import __version__, __version_code__
@@ -145,7 +146,13 @@ def __init__(self, version=None):
# Password file path - use platform-specific folder
fadcrypt_folder = self.get_fadcrypt_folder()
password_file = os.path.join(fadcrypt_folder, "encrypted_password.bin")
- self.password_manager = PasswordManager(password_file, self.crypto_manager)
+ recovery_codes_file = os.path.join(fadcrypt_folder, "recovery_codes.json")
+
+ self.password_manager = PasswordManager(
+ password_file,
+ self.crypto_manager,
+ recovery_codes_file
+ )
# Initialize file lock manager (platform-specific)
self.file_lock_manager = self.get_file_lock_manager(fadcrypt_folder)
@@ -225,6 +232,9 @@ def __init__(self, version=None):
# Connect settings signal
self.settings_panel.settings_changed.connect(self.on_settings_changed)
+ # Connect recovery codes button to handler
+ self.settings_panel.on_generate_recovery_codes = self.on_generate_recovery_codes_clicked
+
# Connect cleanup button to cleanup handler
self.settings_panel.on_cleanup_clicked = self.cleanup_before_uninstall
@@ -376,17 +386,11 @@ def init_system_tray(self):
def show_window_from_tray(self):
"""Show window from system tray - requires password if monitoring is active"""
if self.monitoring_active:
- # Ask for password when monitoring is active (same as legacy)
- from ui.dialogs.password_dialog import ask_password
- password = ask_password(
+ # Ask for password with recovery option
+ if self.verify_password_with_recovery(
"Show Window",
- "Enter your password to show the window:",
- self.resource_path,
- style=self.password_dialog_style,
- wallpaper=self.wallpaper_choice,
- parent=self
- )
- if password and self.password_manager.verify_password(password):
+ "Enter your password to show the window:"
+ ):
self.show()
self.activateWindow()
self.raise_()
@@ -394,7 +398,7 @@ def show_window_from_tray(self):
if self.system_tray:
self.system_tray.show_message(
"Access Denied",
- "Incorrect password. Window remains hidden.",
+ "Window remains hidden.",
QSystemTrayIcon.MessageIcon.Warning
)
else:
@@ -449,6 +453,10 @@ def on_exit_requested(self):
# Stop monitoring first
if self.unified_monitor:
self.unified_monitor.stop_monitoring()
+ # Unprotect critical files
+ print("๐ Unprotecting critical files on exit...")
+ file_protection = get_file_protection_manager()
+ file_protection.unprotect_all_files()
# Really exit the application
from PyQt6.QtWidgets import QApplication
# Cleanup logs widget
@@ -466,6 +474,10 @@ def on_exit_requested(self):
else:
# Not monitoring, exit without password
print("โ
Exiting FadCrypt (no monitoring active)")
+ # Unprotect critical files (in case they were left protected)
+ print("๐ Unprotecting critical files on exit...")
+ file_protection = get_file_protection_manager()
+ file_protection.unprotect_all_files()
from PyQt6.QtWidgets import QApplication
# Cleanup logs widget
if hasattr(self, 'logs_tab_widget'):
@@ -1332,6 +1344,217 @@ def center_on_screen(self):
else:
print("[MainWindow] โ ๏ธ No screen found, cannot center")
+ def verify_password_with_recovery(self, title: str, prompt: str) -> bool:
+ """
+ Verify password with recovery code fallback.
+ Handles "Forgot Password?" flow.
+
+ Returns:
+ True if password verified, False if cancelled or recovery attempted
+ """
+ from ui.dialogs.password_dialog import ask_password
+ from ui.dialogs.recovery_dialog import ask_recovery_code, show_recovery_codes
+
+ while True:
+ # Ask for password with recovery code status
+ password = ask_password(
+ title,
+ prompt,
+ self.resource_path,
+ style=self.password_dialog_style,
+ wallpaper=self.wallpaper_choice,
+ parent=self,
+ has_recovery_codes=self.password_manager.has_recovery_codes()
+ )
+
+ # User cancelled
+ if not password:
+ return False
+
+ # User clicked "Forgot Password?"
+ if password == "RECOVER":
+ if not self.password_manager.has_recovery_codes():
+ self.show_message(
+ "No Recovery Codes",
+ "No recovery codes found. You cannot recover your password.\n"
+ "Your password cannot be reset without backup codes.",
+ "error"
+ )
+ continue
+
+ # Show recovery code dialog with verification callback
+ code, new_pwd = ask_recovery_code(
+ self.resource_path,
+ self,
+ verify_callback=self.password_manager.verify_recovery_code
+ )
+
+ if not code or not new_pwd:
+ continue # User cancelled recovery
+
+ # Attempt password recovery
+ success, error = self.password_manager.recover_password_with_code(
+ code,
+ new_pwd,
+ cleanup_callback=self._password_recovery_cleanup
+ )
+
+ if success:
+ # Ask user if they want to generate recovery codes now
+ self._offer_recovery_code_generation("Password Recovered")
+ return True
+ else:
+ self.show_message(
+ "Recovery Failed",
+ f"โ Password recovery failed:\n{error}",
+ "error"
+ )
+ continue
+
+ # Verify password
+ if self.password_manager.verify_password(password):
+ return True
+ else:
+ # Invalid password - ask again
+ self.show_message(
+ "Invalid Password",
+ "โ Incorrect password. Please try again.\n"
+ "You can click 'Forgot Password?' if you don't remember your password.",
+ "error"
+ )
+ continue
+
+ def _offer_recovery_code_generation(self, success_title: str = "Success"):
+ """
+ Offer user to generate recovery codes with recommendation.
+
+ Args:
+ success_title: Title for success message
+ """
+ from ui.dialogs.recovery_dialog import show_recovery_codes
+
+ # Check if recovery codes already exist
+ has_codes = self.password_manager.has_recovery_codes()
+
+ if has_codes:
+ # Codes exist - warn about invalidation
+ message = (
+ "โ
Password operation successful!\n\n"
+ "โน๏ธ You currently have existing recovery codes.\n\n"
+ "๐ Would you like to generate NEW recovery codes?\n\n"
+ "โ ๏ธ IMPORTANT: Generating new codes will INVALIDATE all old codes!\n"
+ "โข If you have old codes saved, they will no longer work\n"
+ "โข Only the new codes will be valid after generation\n\n"
+ "Recommendation: Only generate new codes if:\n"
+ "โข You've lost your old codes\n"
+ "โข You want to refresh your codes for security"
+ )
+ else:
+ # No codes exist - recommend generation
+ message = (
+ "โ
Password operation successful!\n\n"
+ "๐ Would you like to generate recovery codes now?\n\n"
+ "Recovery codes allow you to reset your password if you forget it.\n"
+ "Without recovery codes, a forgotten password cannot be recovered!\n\n"
+ "โ ๏ธ Highly recommended for account security!"
+ )
+
+ # Ask if user wants to generate codes now
+ reply = QMessageBox.question(
+ self,
+ success_title,
+ message,
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No if has_codes else QMessageBox.StandardButton.Yes
+ )
+
+ if reply == QMessageBox.StandardButton.Yes:
+ success, codes = self.password_manager.create_recovery_codes()
+ if success and codes:
+ show_recovery_codes(codes, self.resource_path, self)
+ if has_codes:
+ self.show_message(
+ "Recovery Codes Regenerated",
+ "โ
New recovery codes generated successfully!\n"
+ "โ ๏ธ All old codes have been invalidated.\n\n"
+ "Save the new codes in a secure place.\n\n"
+ "You can regenerate codes anytime from the main menu.",
+ "success"
+ )
+ else:
+ self.show_message(
+ "Recovery Codes Generated",
+ "โ
Recovery codes generated successfully!\n"
+ "Save them in a secure place.\n\n"
+ "You can regenerate codes anytime from the main menu.",
+ "success"
+ )
+ else:
+ if has_codes:
+ self.show_message(
+ "Recovery Codes Kept",
+ "โน๏ธ Your existing recovery codes remain valid.\n\n"
+ "You can continue using your saved codes if needed.\n\n"
+ "To generate new codes later, use:\n"
+ "Settings โ Generate Recovery Codes",
+ "info"
+ )
+ else:
+ self.show_message(
+ "Recovery Codes Skipped",
+ "โ ๏ธ You chose not to generate recovery codes.\n\n"
+ "Without recovery codes, you CANNOT reset your password if forgotten!\n\n"
+ "You can generate them later from the main menu:\n"
+ "Settings โ Generate Recovery Codes",
+ "warning"
+ )
+
+ def _password_recovery_cleanup(self, new_password: str) -> bool:
+ """
+ Cleanup callback for password recovery.
+ Stops monitoring, unlocks files, resets state.
+
+ Args:
+ new_password: New master password (for re-encryption if needed)
+
+ Returns:
+ True if cleanup successful
+ """
+ try:
+ print("[Recovery] Starting cleanup callback...")
+
+ # Stop monitoring if active
+ if self.monitoring_active:
+ print("[Recovery] Stopping monitoring...")
+ if self.unified_monitor:
+ self.unified_monitor.stop_monitoring()
+ if self.file_access_monitor:
+ self.file_access_monitor.stop_monitoring()
+ self.monitoring_active = False
+
+ # Unlock all files
+ if self.file_lock_manager:
+ print("[Recovery] Unlocking all files...")
+ if hasattr(self.file_lock_manager, 'unlock_all_with_configs'):
+ self.file_lock_manager.unlock_all_with_configs()
+ else:
+ self.file_lock_manager.unlock_all()
+ self.file_lock_manager.unlock_fadcrypt_configs()
+
+ # Reset monitoring state
+ self.monitoring_state = {
+ 'unlocked_apps': [],
+ 'unlocked_files': []
+ }
+ self.save_monitoring_state_to_disk()
+
+ print("[Recovery] โ
Cleanup complete")
+ return True
+
+ except Exception as e:
+ print(f"[Recovery] โ Error during cleanup: {e}")
+ return False
+
def show_message(self, title, message, msg_type="info"):
"""Show a message dialog"""
if msg_type == "info":
@@ -2014,6 +2237,58 @@ def on_start_monitoring(self):
self.unified_monitor.start_monitoring(applications)
self.monitoring_active = True
+ # Protect critical files from deletion/tampering (if enabled in settings)
+ settings_file = os.path.join(self.get_fadcrypt_folder(), 'settings.json')
+ file_protection_enabled = True # Default: enabled
+
+ try:
+ if os.path.exists(settings_file):
+ import json
+ with open(settings_file, 'r') as f:
+ settings = json.load(f)
+ file_protection_enabled = settings.get('file_protection_enabled', True)
+ except Exception as e:
+ print(f"[FileProtection] Could not read settings, using default: {e}")
+
+ if file_protection_enabled:
+ # Show authorization dialog before requesting permissions
+ from ui.dialogs.file_protection_auth_dialog import FileProtectionAuthDialog
+
+ platform_name = "Windows" if sys.platform == 'win32' else "Linux"
+ auth_dialog = FileProtectionAuthDialog(
+ parent=self,
+ platform_name=platform_name,
+ file_count=3
+ )
+
+ dialog_result = auth_dialog.exec()
+
+ if dialog_result == FileProtectionAuthDialog.DialogCode.Accepted:
+ print("๐ก๏ธ Protecting critical files...")
+ file_protection = get_file_protection_manager()
+ fadcrypt_folder = self.get_fadcrypt_folder()
+
+ # List of critical files to protect
+ critical_files = [
+ os.path.join(fadcrypt_folder, "recovery_codes.json"),
+ os.path.join(fadcrypt_folder, "encrypted_password.bin"),
+ os.path.join(fadcrypt_folder, "apps_config.json"),
+ ]
+
+ # Filter to only existing files
+ existing_files = [f for f in critical_files if os.path.exists(f)]
+
+ if existing_files:
+ success_count, errors = file_protection.protect_multiple_files(existing_files)
+ print(f"โ
Protected {success_count}/{len(existing_files)} critical files")
+ if errors:
+ for error in errors:
+ print(f" โ ๏ธ {error}")
+ else:
+ print("โญ๏ธ User skipped file protection")
+ else:
+ print("โญ๏ธ File protection disabled in settings")
+
# Log monitoring start event (needed for duration calculation)
self.log_activity(
'start_monitoring',
@@ -2120,23 +2395,26 @@ def on_stop_monitoring(self):
self.show_message("Info", "Monitoring is not running.", "info")
return
- # Ask for password
- from ui.dialogs.password_dialog import ask_password
- password = ask_password(
+ # Ask for password with recovery option
+ if self.verify_password_with_recovery(
"Stop Monitoring",
- "Enter your password to stop monitoring:",
- self.resource_path,
- style=self.password_dialog_style,
- wallpaper=self.wallpaper_choice,
- parent=self
- )
-
- if password and self.password_manager.verify_password(password):
+ "Enter your password to stop monitoring:"
+ ):
# Stop monitoring
if self.unified_monitor:
self.unified_monitor.stop_monitoring()
print("๐ Monitoring stopped successfully")
+ # Unprotect critical files
+ print("๐ Unprotecting critical files...")
+ file_protection = get_file_protection_manager()
+ success_count, errors = file_protection.unprotect_all_files()
+ if success_count > 0:
+ print(f"โ
Unprotected {success_count} critical files")
+ if errors:
+ for error in errors:
+ print(f" โ ๏ธ {error}")
+
# Stop file access monitoring
if self.file_access_monitor:
print("๐ Stopping file access monitor...")
@@ -2618,18 +2896,11 @@ def show_password_prompt_for_app(self, app_name, app_path):
def show_password_prompt_for_app_sync(self, app_name, app_path):
"""Show password dialog in main thread (thread-safe)"""
- from ui.dialogs.password_dialog import ask_password
-
- password = ask_password(
+ # Use verify_password_with_recovery to handle forgot password flow
+ if self.verify_password_with_recovery(
f"Unlock {app_name}",
- f"Application '{app_name}' is locked.\n\nEnter your password to unlock it:",
- self.resource_path,
- style=self.password_dialog_style,
- wallpaper=self.wallpaper_choice,
- parent=self
- )
-
- if password and self.password_manager.verify_password(password):
+ f"Application '{app_name}' is locked.\n\nEnter your password to unlock it:"
+ ):
print(f"โ
Password correct - Unlocking {app_name}")
# Add to unlocked apps (the monitoring thread will see this and stop blocking)
@@ -2660,7 +2931,7 @@ def show_password_prompt_for_app_sync(self, app_name, app_path):
# Remove from showing dialog set
self.unified_monitor.remove_from_showing_dialog(app_name)
else:
- print(f"โ Password incorrect - Keeping {app_name} locked")
+ print(f"โ Password incorrect or cancelled - Keeping {app_name} locked")
# Log failed unlock attempt
self.log_activity(
@@ -2668,18 +2939,10 @@ def show_password_prompt_for_app_sync(self, app_name, app_path):
app_name,
'application',
success=False,
- details="Wrong password entered"
+ details="Wrong password entered or cancelled"
)
- # Show error message for wrong password
- if password: # Only show error if password was entered (not cancelled)
- self.show_message(
- "Incorrect Password",
- f"The password you entered is incorrect.\n\n{app_name} remains locked.",
- "error"
- )
-
- # Remove from showing dialog set even if password wrong
+ # Remove from showing dialog set
self.unified_monitor.remove_from_showing_dialog(app_name)
def on_readme_clicked(self):
@@ -2777,19 +3040,45 @@ def on_create_password(self):
print(f" โ ๏ธ Password file already exists, cannot create")
self.show_message("Info", "Password already exists. Use 'Change Password' to modify.", "info")
else:
+ # First password entry
password = ask_password(
"Create Password",
"Make sure to securely note down your password.\nIf forgotten, the tool cannot be stopped,\nand recovery will be difficult!\nEnter a new password:",
self.resource_path,
style=self.password_dialog_style,
wallpaper=self.wallpaper_choice,
- parent=self
+ parent=self,
+ show_forgot_password=False # Hide forgot password during creation
)
if password:
+ # Confirm password entry
+ confirm_password = ask_password(
+ "Confirm New Password", # Changed to include "New Password" for "Create" button
+ "Please re-enter your password to confirm:",
+ self.resource_path,
+ style=self.password_dialog_style,
+ wallpaper=self.wallpaper_choice,
+ parent=self,
+ show_forgot_password=False # Hide forgot password during creation
+ )
+
+ if not confirm_password:
+ print(f" โ ๏ธ Password confirmation cancelled")
+ return
+
+ if password != confirm_password:
+ print(f" โ Passwords don't match")
+ self.show_message("Error", "Passwords don't match. Please try again.", "error")
+ return
+
try:
print(f" Creating password file at: {password_file}")
self.password_manager.create_password(password)
print(f" โ
Password created successfully")
+
+ # Offer recovery code generation
+ self._offer_recovery_code_generation("Password Created")
+
self.show_message("Success", "Password created successfully.", "success")
except Exception as e:
print(f" โ Error creating password: {e}")
@@ -2804,14 +3093,54 @@ def on_change_password(self):
print(f" File exists: {os.path.exists(password_file)}")
if os.path.exists(password_file):
+ # Ask for old password with recovery option
old_password = ask_password(
"Change Password",
"Enter your old password:",
self.resource_path,
style=self.password_dialog_style,
wallpaper=self.wallpaper_choice,
- parent=self
+ parent=self,
+ show_forgot_password=True,
+ has_recovery_codes=self.password_manager.has_recovery_codes()
)
+
+ if old_password == "RECOVER":
+ # User clicked forgot password - show recovery dialog
+ from ui.dialogs.recovery_dialog import ask_recovery_code
+
+ if not self.password_manager.has_recovery_codes():
+ self.show_message(
+ "No Recovery Codes",
+ "โ No recovery codes found!\n\n"
+ "You cannot recover your password without recovery codes.\n"
+ "Recovery codes must be generated from the Settings menu first.",
+ "error"
+ )
+ return
+
+ code, new_pwd = ask_recovery_code(
+ self.resource_path,
+ self,
+ verify_callback=self.password_manager.verify_recovery_code
+ )
+
+ if not code or not new_pwd:
+ return # User cancelled
+
+ # Recover password
+ success, error = self.password_manager.recover_password_with_code(
+ code,
+ new_pwd,
+ cleanup_callback=self._password_recovery_cleanup
+ )
+
+ if success:
+ self._offer_recovery_code_generation("Password Recovered")
+ else:
+ self.show_message("Recovery Failed", f"โ {error}", "error")
+ return
+
if old_password and self.password_manager.verify_password(old_password):
print(f" โ
Old password verified")
new_password = ask_password(
@@ -2820,23 +3149,84 @@ def on_change_password(self):
self.resource_path,
style=self.password_dialog_style,
wallpaper=self.wallpaper_choice,
- parent=self
+ parent=self,
+ show_forgot_password=False
)
if new_password:
try:
print(f" Changing password at: {password_file}")
self.password_manager.change_password(old_password, new_password)
print(f" โ
Password changed successfully")
- self.show_message("Success", "Password changed successfully.", "success")
+
+ # Offer recovery code generation
+ self._offer_recovery_code_generation("Password Changed")
+
except Exception as e:
print(f" โ Error changing password: {e}")
self.show_message("Error", f"Failed to change password:\n{e}", "error")
else:
- print(f" โ Old password verification failed")
- self.show_message("Error", "Incorrect old password.", "error")
+ print(f" โ Password verification failed")
+ self.show_message("Error", "Incorrect old password", "error")
else:
print(f" โ ๏ธ No password file found")
self.show_message("Oops!", "How do I change a password that doesn't exist? :(", "warning")
+
+ def on_generate_recovery_codes_clicked(self):
+ """Handle generate recovery codes button click from settings"""
+ from ui.dialogs.password_dialog import ask_password
+ from ui.dialogs.recovery_dialog import show_recovery_codes
+
+ password_file = os.path.join(self.get_fadcrypt_folder(), "encrypted_password.bin")
+
+ # Check if password exists
+ if not os.path.exists(password_file):
+ self.show_message(
+ "No Password Set",
+ "You need to create a password first before generating recovery codes.",
+ "warning"
+ )
+ return
+
+ # Ask for password to verify user identity
+ password = ask_password(
+ "Generate Recovery Codes",
+ "Enter your master password to generate recovery codes:",
+ self.resource_path,
+ style=self.password_dialog_style,
+ wallpaper=self.wallpaper_choice,
+ parent=self,
+ show_forgot_password=False
+ )
+
+ if not password:
+ return
+
+ # Verify password
+ if not self.password_manager.verify_password(password):
+ self.show_message(
+ "Invalid Password",
+ "Incorrect password. Recovery codes not generated.",
+ "error"
+ )
+ return
+
+ # Generate recovery codes
+ success, codes = self.password_manager.create_recovery_codes()
+ if success and codes:
+ show_recovery_codes(codes, self.resource_path, self)
+ self.show_message(
+ "Recovery Codes Generated",
+ "โ
Recovery codes generated successfully!\n\n"
+ "โ ๏ธ These replace any previous recovery codes.\n"
+ "Save them in a secure place.",
+ "success"
+ )
+ else:
+ self.show_message(
+ "Generation Failed",
+ "Failed to generate recovery codes. Please try again.",
+ "error"
+ )
def on_snake_game(self):
"""Handle snake game button click"""
diff --git a/ui/components/settings_panel.py b/ui/components/settings_panel.py
index 79a8790..c906c0c 100644
--- a/ui/components/settings_panel.py
+++ b/ui/components/settings_panel.py
@@ -56,8 +56,8 @@ def init_ui(self):
left_frame.setSpacing(10)
# Password Dialog Style
- dialog_style_label = QLabel("Password Dialog Style:")
- dialog_style_label.setStyleSheet("font-weight: bold;")
+ dialog_style_label = QLabel("๐จ Password Dialog Style")
+ dialog_style_label.setStyleSheet("font-size: 11px; font-weight: bold;")
left_frame.addWidget(dialog_style_label)
self.dialog_style_group = QButtonGroup()
@@ -113,8 +113,8 @@ def init_ui(self):
left_frame.addSpacing(20)
# Wallpaper Choice
- wallpaper_label = QLabel("Full Screen Wallpaper:")
- wallpaper_label.setStyleSheet("font-weight: bold;")
+ wallpaper_label = QLabel("๐ผ๏ธ Full Screen Wallpaper")
+ wallpaper_label.setStyleSheet("font-size: 11px; font-weight: bold;")
left_frame.addWidget(wallpaper_label)
self.wallpaper_group = QButtonGroup()
@@ -168,8 +168,8 @@ def init_ui(self):
right_frame = QVBoxLayout()
right_frame.setSpacing(10)
- preview_label = QLabel("Dialog Preview:")
- preview_label.setStyleSheet("font-weight: bold;")
+ preview_label = QLabel("๐๏ธ Dialog Preview")
+ preview_label.setStyleSheet("font-size: 11px; font-weight: bold;")
right_frame.addWidget(preview_label)
# Preview frame - no border, just dark background
@@ -205,8 +205,8 @@ def init_ui(self):
bottom_frame.setSpacing(10)
# Disable Main Loopholes
- loopholes_title = QLabel("Disable Main loopholes")
- loopholes_title.setStyleSheet("font-weight: bold;")
+ loopholes_title = QLabel("๐ Disable Main Loopholes")
+ loopholes_title.setStyleSheet("font-size: 11px; font-weight: bold;")
bottom_frame.addWidget(loopholes_title)
self.lock_tools_checkbox = QCheckBox(
@@ -242,12 +242,89 @@ def init_ui(self):
lock_tools_info.setWordWrap(True)
bottom_frame.addWidget(lock_tools_info)
+ # File Protection Section
+ separator_file_protection = QFrame()
+ separator_file_protection.setFrameShape(QFrame.Shape.HLine)
+ separator_file_protection.setFrameShadow(QFrame.Shadow.Sunken)
+ bottom_frame.addWidget(separator_file_protection)
+
+ file_protection_title = QLabel("๐ก๏ธ Critical File Protection")
+ file_protection_title.setStyleSheet("font-size: 11px; font-weight: bold;")
+ bottom_frame.addWidget(file_protection_title)
+
+ self.file_protection_checkbox = QCheckBox(
+ "Enable file protection during monitoring (Recommended)"
+ )
+ self.file_protection_checkbox.setChecked(True) # Default: Enabled
+ self.file_protection_checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #e0e0e0;
+ spacing: 8px;
+ }
+ QCheckBox::indicator {
+ width: 18px;
+ height: 18px;
+ border: 2px solid #666666;
+ border-radius: 3px;
+ background-color: #2a2a2a;
+ }
+ QCheckBox::indicator:checked {
+ border: 2px solid #d32f2f;
+ background-color: #d32f2f;
+ image: url(none);
+ }
+ QCheckBox::indicator:hover {
+ border: 2px solid #888888;
+ }
+ """)
+ bottom_frame.addWidget(self.file_protection_checkbox)
+
+ # Info text below checkbox
+ file_protection_info = QLabel(
+ self._get_file_protection_info_text()
+ )
+ file_protection_info.setStyleSheet("color: #666666; font-size: 11px; padding-left: 26px;")
+ file_protection_info.setWordWrap(True)
+ bottom_frame.addWidget(file_protection_info)
+
# Uninstall Cleanup
separator3 = QFrame()
separator3.setFrameShape(QFrame.Shape.HLine)
separator3.setFrameShadow(QFrame.Shadow.Sunken)
bottom_frame.addWidget(separator3)
+ # Recovery Codes Section
+ recovery_title = QLabel("๐ Recovery Codes")
+ recovery_title.setStyleSheet("font-size: 11px; font-weight: bold;")
+ bottom_frame.addWidget(recovery_title)
+
+ recovery_info = QLabel(
+ "Generate or regenerate recovery codes for password recovery.\n"
+ "Keep these codes safe - they allow you to reset your password if forgotten."
+ )
+ recovery_info.setStyleSheet("color: #888888;")
+ recovery_info.setWordWrap(True)
+ bottom_frame.addWidget(recovery_info)
+
+ recovery_button = QPushButton("Generate Recovery Codes")
+ recovery_button.setStyleSheet("""
+ QPushButton {
+ background-color: #1976d2;
+ color: white;
+ font-weight: bold;
+ padding: 8px 20px;
+ border-radius: 5px;
+ }
+ QPushButton:hover {
+ background-color: #1565c0;
+ }
+ """)
+ recovery_button.clicked.connect(lambda: self.on_generate_recovery_codes())
+ recovery_button.setMaximumWidth(250)
+ bottom_frame.addWidget(recovery_button)
+
+ bottom_frame.addSpacing(20)
+
cleanup_title = QLabel("๐ง Uninstall Cleanup")
cleanup_title.setStyleSheet("font-size: 11px; font-weight: bold;")
bottom_frame.addWidget(cleanup_title)
@@ -290,6 +367,7 @@ def init_ui(self):
self.dialog_style_group.buttonClicked.connect(self.on_settings_changed)
self.wallpaper_group.buttonClicked.connect(self.on_settings_changed)
self.lock_tools_checkbox.stateChanged.connect(self.on_settings_changed)
+ self.file_protection_checkbox.stateChanged.connect(self.on_settings_changed)
# Initial preview update
self.update_preview()
@@ -329,6 +407,11 @@ def on_settings_changed(self):
self.settings_changed.emit(settings)
self.update_preview()
+ def on_generate_recovery_codes(self):
+ """Handle recovery codes button click"""
+ # To be implemented by main window
+ pass
+
def on_cleanup_clicked(self):
"""Handle cleanup button click"""
# To be implemented by main window
@@ -339,7 +422,8 @@ def get_settings(self):
return {
'dialog_style': 'simple' if self.simple_dialog_radio.isChecked() else 'fullscreen',
'wallpaper': self.get_wallpaper_choice(),
- 'lock_tools': self.lock_tools_checkbox.isChecked()
+ 'lock_tools': self.lock_tools_checkbox.isChecked(),
+ 'file_protection_enabled': self.file_protection_checkbox.isChecked()
}
def get_wallpaper_choice(self):
@@ -373,6 +457,7 @@ def set_settings(self, settings):
self.lab_wallpaper_radio.setChecked(True)
self.lock_tools_checkbox.setChecked(settings.get('lock_tools', False)) # Default: False for safety
+ self.file_protection_checkbox.setChecked(settings.get('file_protection_enabled', True)) # Default: True (enabled)
self.on_settings_changed()
@@ -401,3 +486,20 @@ def _get_lock_tools_info_text(self):
"For password-protected access instead, keep this DISABLED and add terminals to the Application tab. "
"(Tools: gnome-terminal, konsole, xterm, gnome-system-monitor, htop, top)"
)
+
+ def _get_file_protection_info_text(self):
+ """Get platform-specific info text for file protection"""
+ if self.platform_name == "Windows":
+ return (
+ "Protects critical files (config, password, recovery codes) from deletion/modification during monitoring. "
+ "Files are made Hidden + System + ReadOnly. "
+ "When you stop monitoring, files will be automatically unlocked. "
+ "โ ๏ธ Note: Requires administrator permission to protect and unprotect files."
+ )
+ else: # Linux
+ return (
+ "Protects critical files (config, password, recovery codes) from deletion/modification during monitoring. "
+ "Files are made immutable (chattr +i) - even root cannot delete them! "
+ "When you stop monitoring, you'll be prompted to authorize file unlocking. "
+ "โ ๏ธ Note: Requires authorization (pkexec/sudo) to protect and unprotect files."
+ )
diff --git a/ui/dialogs/file_protection_auth_dialog.py b/ui/dialogs/file_protection_auth_dialog.py
new file mode 100644
index 0000000..ebe8cc3
--- /dev/null
+++ b/ui/dialogs/file_protection_auth_dialog.py
@@ -0,0 +1,171 @@
+"""File Protection Authorization Dialog for FadCrypt"""
+
+import sys
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel,
+ QPushButton, QFrame
+)
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QIcon
+
+
+class FileProtectionAuthDialog(QDialog):
+ """Dialog to explain and request authorization for file protection"""
+
+ def __init__(self, parent=None, platform_name="Linux", file_count=3):
+ super().__init__(parent)
+ self.platform_name = platform_name
+ self.file_count = file_count
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize the dialog UI"""
+ self.setWindowTitle("File Protection Authorization Required")
+ self.setFixedWidth(500)
+
+ # Apply dark theme styling
+ self.setStyleSheet("""
+ QDialog {
+ background-color: #1e1e1e;
+ color: #e0e0e0;
+ }
+ QLabel {
+ color: #e0e0e0;
+ }
+ """)
+
+ layout = QVBoxLayout(self)
+ layout.setSpacing(15)
+ layout.setContentsMargins(20, 20, 20, 20)
+
+ # Icon + Title
+ title_layout = QHBoxLayout()
+ title_icon = QLabel("๐ก๏ธ")
+ title_icon.setStyleSheet("font-size: 32px;")
+ title_layout.addWidget(title_icon)
+
+ title_label = QLabel("File Protection Authorization")
+ title_label.setStyleSheet("font-size: 16px; font-weight: bold;")
+ title_layout.addWidget(title_label)
+ title_layout.addStretch()
+ layout.addLayout(title_layout)
+
+ # Separator
+ separator1 = QFrame()
+ separator1.setFrameShape(QFrame.Shape.HLine)
+ separator1.setFrameShadow(QFrame.Shadow.Sunken)
+ separator1.setStyleSheet("background-color: #444444;")
+ layout.addWidget(separator1)
+
+ # What will be protected
+ what_label = QLabel("What will be protected:")
+ layout.addWidget(what_label)
+
+ files_list = QLabel(
+ f"โข {self.file_count} critical files will be protected:
"
+ " - Password file (encrypted_password.bin)
"
+ " - Recovery codes (recovery_codes.json)
"
+ " - Application config (apps_config.json)"
+ )
+ files_list.setStyleSheet("padding-left: 15px; color: #b0b0b0;")
+ files_list.setWordWrap(True)
+ layout.addWidget(files_list)
+
+ # Why it's needed
+ why_label = QLabel("Why this is needed:")
+ layout.addWidget(why_label)
+
+ why_text = QLabel(
+ "These files contain sensitive security data. Protection prevents "
+ "them from being deleted or modified while monitoring is active, "
+ "ensuring your password and configuration remain intact."
+ )
+ why_text.setStyleSheet("padding-left: 15px; color: #b0b0b0;")
+ why_text.setWordWrap(True)
+ layout.addWidget(why_text)
+
+ # How it works (platform-specific)
+ how_label = QLabel("How it works:")
+ layout.addWidget(how_label)
+
+ if self.platform_name == "Windows":
+ how_text = QLabel(
+ "Files will be marked as Hidden + System + ReadOnly using Windows file attributes. "
+ "This requires administrator permission via UAC (User Account Control) prompt."
+ )
+ else: # Linux
+ how_text = QLabel(
+ "Files will be made immutable (chattr +i) - even root cannot delete them! "
+ "This requires authorization via PolicyKit (pkexec) or sudo prompt."
+ )
+ how_text.setStyleSheet("padding-left: 15px; color: #b0b0b0;")
+ how_text.setWordWrap(True)
+ layout.addWidget(how_text)
+
+ # When unlocking
+ separator2 = QFrame()
+ separator2.setFrameShape(QFrame.Shape.HLine)
+ separator2.setFrameShadow(QFrame.Shadow.Sunken)
+ separator2.setStyleSheet("background-color: #444444;")
+ layout.addWidget(separator2)
+
+ unlock_warning = QLabel(
+ "โ ๏ธ Note: When you stop monitoring, you will be prompted again "
+ "to authorize unlocking these files."
+ )
+ unlock_warning.setStyleSheet("color: #ff9800; padding: 10px; background-color: #2a2a2a; border-radius: 5px;")
+ unlock_warning.setWordWrap(True)
+ layout.addWidget(unlock_warning)
+
+ # Spacer
+ layout.addStretch()
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ button_layout.addStretch()
+
+ skip_button = QPushButton("Skip Protection")
+ skip_button.setStyleSheet("""
+ QPushButton {
+ background-color: #424242;
+ color: #e0e0e0;
+ font-weight: bold;
+ padding: 10px 25px;
+ border: none;
+ border-radius: 5px;
+ }
+ QPushButton:hover {
+ background-color: #4a4a4a;
+ }
+ QPushButton:pressed {
+ background-color: #383838;
+ }
+ """)
+ skip_button.clicked.connect(self.reject)
+ button_layout.addWidget(skip_button)
+
+ grant_button = QPushButton("Grant Permission")
+ grant_button.setStyleSheet("""
+ QPushButton {
+ background-color: #4caf50;
+ color: white;
+ font-weight: bold;
+ padding: 10px 25px;
+ border: none;
+ border-radius: 5px;
+ }
+ QPushButton:hover {
+ background-color: #5cb860;
+ }
+ QPushButton:pressed {
+ background-color: #449d48;
+ }
+ """)
+ grant_button.clicked.connect(self.accept)
+ grant_button.setDefault(True)
+ button_layout.addWidget(grant_button)
+
+ layout.addLayout(button_layout)
+
+ # Set focus to grant button
+ grant_button.setFocus()
diff --git a/ui/dialogs/password_dialog.py b/ui/dialogs/password_dialog.py
index a929132..533c7ea 100644
--- a/ui/dialogs/password_dialog.py
+++ b/ui/dialogs/password_dialog.py
@@ -1,7 +1,7 @@
"""Password Dialog for FadCrypt"""
from PyQt6.QtWidgets import (
- QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFrame
+ QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFrame, QProgressBar, QSizePolicy
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QPixmap, QFont
@@ -10,7 +10,7 @@
class PasswordDialog(QDialog):
"""Custom password dialog with optional fullscreen wallpaper background"""
- def __init__(self, title, prompt, resource_path, fullscreen=False, wallpaper_choice=None, parent=None):
+ def __init__(self, title, prompt, resource_path, fullscreen=False, wallpaper_choice=None, parent=None, show_forgot_password=True, has_recovery_codes=True):
# For fullscreen mode, don't use parent to avoid being hidden when parent is minimized
if fullscreen:
super().__init__(None) # Independent top-level window
@@ -21,6 +21,8 @@ def __init__(self, title, prompt, resource_path, fullscreen=False, wallpaper_cho
self.fullscreen = fullscreen
self.wallpaper_choice = wallpaper_choice
self.password_value = None
+ self.show_forgot_password = show_forgot_password
+ self.has_recovery_codes = has_recovery_codes
self.setWindowTitle(title)
self.init_ui(title, prompt)
@@ -74,11 +76,17 @@ def init_ui(self, title, prompt):
""")
if self.fullscreen:
- content_frame.setFixedSize(440, 240)
+ # Set minimum size but allow dynamic expansion
+ content_frame.setMinimumSize(440, 240)
+ content_frame.setMaximumWidth(600) # Max width for readability
content_layout = QVBoxLayout(content_frame)
- content_layout.setContentsMargins(30, 25, 30, 25)
- content_layout.setSpacing(12)
+ if self.fullscreen:
+ content_layout.setContentsMargins(40, 30, 40, 30) # More padding for fullscreen
+ content_layout.setSpacing(15) # More spacing for fullscreen
+ else:
+ content_layout.setContentsMargins(30, 25, 30, 25)
+ content_layout.setSpacing(12)
# Title label - compact
title_label = QLabel(title)
@@ -98,20 +106,24 @@ def init_ui(self, title, prompt):
# Prompt label - responsive with proper wrapping
prompt_label = QLabel(prompt)
prompt_label.setWordWrap(True)
- from PyQt6.QtWidgets import QSizePolicy
prompt_label.setSizePolicy(
- QSizePolicy.Policy.Preferred,
+ QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Minimum
)
- # Set maximum width for text wrapping
- prompt_label.setMaximumWidth(380)
+ # Set maximum width for text wrapping based on mode
+ if self.fullscreen:
+ prompt_label.setMaximumWidth(520) # Wider for fullscreen
+ else:
+ prompt_label.setMaximumWidth(380)
+
prompt_label.setStyleSheet("""
QLabel {
font-size: 11px;
color: #a0a0a0;
border: none;
- padding: 0;
+ padding: 8px 0;
margin: 0;
+ line-height: 1.5;
}
""")
prompt_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -145,9 +157,101 @@ def init_ui(self, title, prompt):
self.password_input.returnPressed.connect(self.on_ok)
content_layout.addWidget(self.password_input)
+ # Password strength meter - only show during password creation
+ if title and ("Create" in title or "New Password" in title):
+ # Strength meter container
+ strength_layout = QVBoxLayout()
+ strength_layout.setSpacing(4)
+ strength_layout.setContentsMargins(0, 8, 0, 0)
+
+ # Strength meter label
+ self.strength_label = QLabel("Password Strength: -")
+ self.strength_label.setStyleSheet("""
+ QLabel {
+ font-size: 11px;
+ color: #888888;
+ border: none;
+ }
+ """)
+ strength_layout.addWidget(self.strength_label)
+
+ # Strength meter bar
+ self.strength_meter = QProgressBar()
+ self.strength_meter.setFixedHeight(8)
+ self.strength_meter.setTextVisible(False)
+ self.strength_meter.setRange(0, 100)
+ self.strength_meter.setValue(0)
+ self.strength_meter.setStyleSheet("""
+ QProgressBar {
+ border: none;
+ background-color: #2b2b2b;
+ border-radius: 4px;
+ }
+ QProgressBar::chunk {
+ background-color: #666666;
+ border-radius: 4px;
+ }
+ """)
+ strength_layout.addWidget(self.strength_meter)
+
+ content_layout.addLayout(strength_layout)
+
+ # Connect password input to strength meter
+ self.password_input.textChanged.connect(self.update_password_strength)
+ else:
+ self.strength_label = None
+ self.strength_meter = None
+
# Spacer before buttons
content_layout.addSpacing(10)
+ # "Forgot Password?" link - only show if enabled
+ if self.show_forgot_password:
+ forgot_link = QPushButton("๐ Forgot Password?")
+ forgot_link.setFlat(True)
+ forgot_link.setCursor(self.cursor())
+ forgot_link.setStyleSheet("""
+ QPushButton {
+ color: #d32f2f;
+ border: none;
+ background: transparent;
+ padding: 0;
+ font-size: 12px;
+ text-decoration: none;
+ }
+ QPushButton:hover {
+ color: #b71c1c;
+ }
+ """)
+ forgot_link.clicked.connect(self.on_forgot_password)
+ forgot_layout = QHBoxLayout()
+ forgot_layout.addStretch()
+ forgot_layout.addWidget(forgot_link)
+ forgot_layout.addStretch()
+ content_layout.addLayout(forgot_layout)
+
+ # Warning if no recovery codes
+ if not self.has_recovery_codes:
+ warning_label = QLabel(
+ "โ ๏ธ No recovery codes generated!\n"
+ "Generate them from Settings โ Generate Recovery Codes"
+ )
+ warning_label.setStyleSheet("""
+ QLabel {
+ color: #ff9800;
+ font-size: 11px;
+ background-color: #2a2a2a;
+ border: 1px solid #ff9800;
+ border-radius: 4px;
+ padding: 8px;
+ }
+ """)
+ warning_label.setWordWrap(True)
+ warning_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ content_layout.addWidget(warning_label)
+
+ content_layout.addSpacing(10)
+
# Buttons - compact design
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
@@ -173,7 +277,13 @@ def init_ui(self, title, prompt):
""")
cancel_button.clicked.connect(self.reject)
- ok_button = QPushButton("Unlock")
+ # OK button - dynamic text based on dialog type
+ if title and ("Create" in title or "New Password" in title):
+ button_text = "Create"
+ else:
+ button_text = "Unlock"
+
+ ok_button = QPushButton(button_text)
ok_button.setFixedSize(120, 36)
ok_button.setStyleSheet("""
QPushButton {
@@ -297,12 +407,98 @@ def set_wallpaper_background(self):
# Fallback to dark background
self.setStyleSheet("QDialog { background-color: #1a1a1a; }")
+ def calculate_password_strength(self, password: str) -> tuple[int, str, str]:
+ """
+ Calculate password strength score and return (score, label, color).
+
+ Args:
+ password: Password to evaluate
+
+ Returns:
+ Tuple of (score 0-100, strength label, color hex)
+ """
+ if not password:
+ return (0, "-", "#666666")
+
+ score = 0
+ length = len(password)
+
+ # Length scoring (0-40 points)
+ if length >= 1:
+ score += min(length * 3, 40)
+
+ # Character variety (0-60 points)
+ has_lower = any(c.islower() for c in password)
+ has_upper = any(c.isupper() for c in password)
+ has_digit = any(c.isdigit() for c in password)
+ has_special = any(not c.isalnum() for c in password)
+
+ variety_score = 0
+ if has_lower: variety_score += 10
+ if has_upper: variety_score += 10
+ if has_digit: variety_score += 15
+ if has_special: variety_score += 25
+
+ score += variety_score
+
+ # Cap at 100
+ score = min(score, 100)
+
+ # Determine strength label and color
+ if score < 25:
+ return (score, "Very Weak", "#d32f2f") # Red
+ elif score < 45:
+ return (score, "Weak", "#ff5722") # Deep Orange
+ elif score < 65:
+ return (score, "Fair", "#ff9800") # Orange
+ elif score < 85:
+ return (score, "Good", "#4caf50") # Green
+ else:
+ return (score, "Strong", "#2e7d32") # Dark Green
+
+ def update_password_strength(self, text: str):
+ """Update password strength meter based on input"""
+ if self.strength_meter and self.strength_label:
+ score, label, color = self.calculate_password_strength(text)
+
+ # Update meter value
+ self.strength_meter.setValue(score)
+
+ # Update meter color
+ self.strength_meter.setStyleSheet(f"""
+ QProgressBar {{
+ border: none;
+ background-color: #2b2b2b;
+ border-radius: 4px;
+ }}
+ QProgressBar::chunk {{
+ background-color: {color};
+ border-radius: 4px;
+ }}
+ """)
+
+ # Update label
+ self.strength_label.setText(f"Password Strength: {label}")
+ self.strength_label.setStyleSheet(f"""
+ QLabel {{
+ font-size: 11px;
+ color: {color};
+ border: none;
+ font-weight: bold;
+ }}
+ """)
+
def on_ok(self):
"""Handle OK button click"""
self.password_value = self.password_input.text()
if self.password_value:
self.accept()
+ def on_forgot_password(self):
+ """Handle forgot password - user needs to recover with code"""
+ self.password_value = "RECOVER" # Special marker for recovery flow
+ self.accept()
+
def get_password(self):
"""Get the entered password"""
return self.password_value
@@ -315,7 +511,7 @@ def keyPressEvent(self, event):
super().keyPressEvent(event)
-def ask_password(title, prompt, resource_path, style="simple", wallpaper="default", parent=None):
+def ask_password(title, prompt, resource_path, style="simple", wallpaper="default", parent=None, show_forgot_password=True, has_recovery_codes=True):
"""
Helper function to show password dialog.
@@ -326,12 +522,14 @@ def ask_password(title, prompt, resource_path, style="simple", wallpaper="defaul
style: "simple" or "fullscreen"
wallpaper: Wallpaper choice ("default", "H4ck3r", "Binary", "encrypted")
parent: Parent widget
+ show_forgot_password: Show "Forgot Password?" link (default: True)
+ has_recovery_codes: Whether recovery codes exist (for warning message)
Returns:
Password string or None if cancelled
"""
fullscreen = (style == "fullscreen")
- dialog = PasswordDialog(title, prompt, resource_path, fullscreen, wallpaper, parent)
+ dialog = PasswordDialog(title, prompt, resource_path, fullscreen, wallpaper, parent, show_forgot_password, has_recovery_codes)
if dialog.exec() == QDialog.DialogCode.Accepted:
return dialog.get_password()
diff --git a/ui/dialogs/recovery_dialog.py b/ui/dialogs/recovery_dialog.py
new file mode 100644
index 0000000..f8b8453
--- /dev/null
+++ b/ui/dialogs/recovery_dialog.py
@@ -0,0 +1,920 @@
+"""Password Recovery Dialog - For using recovery codes to reset password"""
+
+from PyQt6.QtWidgets import (
+ QDialog, QVBoxLayout, QLabel, QLineEdit, QPushButton, QHBoxLayout, QFrame,
+ QTabWidget, QScrollArea, QCheckBox, QMessageBox, QFileDialog, QProgressBar
+)
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QFont
+import os
+
+
+class RecoveryCodeDialog(QDialog):
+ """Dialog for entering recovery code and creating new password"""
+
+ def __init__(self, title, resource_path, parent=None, verify_callback=None):
+ super().__init__(parent)
+ self.resource_path = resource_path
+ self.recovery_code_value = None
+ self.new_password_value = None
+ self.code_verified = False
+ self.tab_widget = None
+ self.strength_label = None
+ self.strength_meter = None
+ self.verify_callback = verify_callback # Callback to verify recovery code
+
+ self.setWindowTitle(title)
+ self.init_ui(title)
+
+ def init_ui(self, title):
+ """Initialize recovery code dialog UI"""
+ self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint)
+ self.setStyleSheet("QDialog { background-color: #1a1a1a; }")
+
+ # Main layout
+ main_layout = QVBoxLayout()
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Content frame - dynamic size
+ content_frame = QFrame()
+ content_frame.setStyleSheet("""
+ QFrame {
+ background-color: #1e1e1e;
+ border: none;
+ border-radius: 10px;
+ }
+ """)
+
+ content_layout = QVBoxLayout(content_frame)
+ content_layout.setContentsMargins(30, 25, 30, 25)
+ content_layout.setSpacing(15)
+
+ # Title
+ title_label = QLabel(title)
+ title_label.setStyleSheet("""
+ QLabel {
+ font-size: 16px;
+ font-weight: bold;
+ color: #ffffff;
+ border: none;
+ }
+ """)
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ content_layout.addWidget(title_label)
+
+ # Tab widget for recovery code and new password
+ self.tab_widget = QTabWidget()
+ self.tab_widget.setStyleSheet("""
+ QTabWidget::pane { border: none; }
+ QTabBar::tab { background-color: #2a2a2a; color: #e0e0e0; padding: 8px 15px; }
+ QTabBar::tab:selected { background-color: #d32f2f; color: white; }
+ QTabBar::tab:disabled { background-color: #1a1a1a; color: #555555; }
+ """)
+
+ # Tab 1: Recovery Code
+ code_tab = QFrame()
+ code_layout = QVBoxLayout(code_tab)
+ code_layout.setContentsMargins(15, 15, 15, 15)
+ code_layout.setSpacing(12)
+
+ code_help = QLabel(
+ "Enter one of your 10 recovery codes saved when you created your password.\n"
+ "Format: XXXX-XXXX-XXXX-XXXX (or spaces: XXXX XXXX XXXX XXXX)"
+ )
+ code_help.setWordWrap(True)
+ code_help.setStyleSheet("""
+ QLabel {
+ font-size: 12px;
+ color: #a0a0a0;
+ padding: 0;
+ line-height: 1.4;
+ }
+ """)
+ code_layout.addWidget(code_help)
+
+ self.recovery_code_input = QLineEdit()
+ self.recovery_code_input.setPlaceholderText("XXXX-XXXX-XXXX-XXXX")
+ self.recovery_code_input.setFixedHeight(38)
+ self.recovery_code_input.setStyleSheet("""
+ QLineEdit {
+ padding: 0 14px;
+ font-size: 13px;
+ font-family: monospace;
+ border: 2px solid #3a3a3a;
+ border-radius: 6px;
+ background-color: #2b2b2b;
+ color: #ffffff;
+ }
+ QLineEdit:focus {
+ border: 2px solid #d32f2f;
+ background-color: #2e2e2e;
+ }
+ """)
+ # Connect Enter key to move to next tab
+ self.recovery_code_input.returnPressed.connect(self.on_code_enter_pressed)
+ code_layout.addWidget(self.recovery_code_input)
+
+ # Next button to proceed to password entry
+ next_button = QPushButton("Next โก๏ธ")
+ next_button.setFixedHeight(36)
+ next_button.setStyleSheet("""
+ QPushButton {
+ background-color: #1976d2;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ QPushButton:hover { background-color: #1565c0; }
+ QPushButton:pressed { background-color: #0d47a1; }
+ """)
+ next_button.clicked.connect(self.on_code_enter_pressed)
+ code_layout.addWidget(next_button)
+
+ warning_label = QLabel(
+ "โ ๏ธ Warning: Each recovery code can only be used ONCE.\n"
+ "After using a code, you won't be able to use it again."
+ )
+ warning_label.setWordWrap(True)
+ warning_label.setStyleSheet("""
+ QLabel {
+ font-size: 12px;
+ color: #ff6b6b;
+ padding: 12px;
+ background-color: #3a2a2a;
+ border-radius: 4px;
+ line-height: 1.4;
+ }
+ """)
+ code_layout.addWidget(warning_label)
+ code_layout.addStretch()
+
+ self.tab_widget.addTab(code_tab, "1๏ธโฃ Recovery Code")
+
+ # Tab 2: New Password (initially disabled)
+ pwd_tab = QFrame()
+ pwd_layout = QVBoxLayout(pwd_tab)
+ pwd_layout.setContentsMargins(15, 15, 15, 15)
+ pwd_layout.setSpacing(12)
+
+ pwd_help = QLabel(
+ "Enter a NEW master password for your account.\n"
+ "This will replace your old password completely.\n"
+ "After recovery, you'll receive 10 new recovery codes."
+ )
+ pwd_help.setWordWrap(True)
+ pwd_help.setStyleSheet("""
+ QLabel {
+ font-size: 12px;
+ color: #a0a0a0;
+ padding: 0;
+ line-height: 1.4;
+ }
+ """)
+ pwd_layout.addWidget(pwd_help)
+
+ pwd_label = QLabel("New Master Password:")
+ pwd_label.setStyleSheet("QLabel { color: #e0e0e0; font-size: 12px; }")
+ pwd_layout.addWidget(pwd_label)
+
+ self.new_password_input = QLineEdit()
+ self.new_password_input.setEchoMode(QLineEdit.EchoMode.Password)
+ self.new_password_input.setPlaceholderText("Enter new master password")
+ self.new_password_input.setFixedHeight(38)
+ self.new_password_input.setStyleSheet("""
+ QLineEdit {
+ padding: 0 14px;
+ font-size: 13px;
+ border: 2px solid #3a3a3a;
+ border-radius: 6px;
+ background-color: #2b2b2b;
+ color: #ffffff;
+ }
+ QLineEdit:focus {
+ border: 2px solid #d32f2f;
+ background-color: #2e2e2e;
+ }
+ """)
+ self.new_password_input.textChanged.connect(self.update_password_strength)
+ pwd_layout.addWidget(self.new_password_input)
+
+ # Password strength meter
+ strength_layout = QVBoxLayout()
+ strength_layout.setSpacing(4)
+ strength_layout.setContentsMargins(0, 8, 0, 0)
+
+ self.strength_label = QLabel("Password Strength: -")
+ self.strength_label.setStyleSheet("""
+ QLabel {
+ font-size: 11px;
+ color: #888888;
+ border: none;
+ }
+ """)
+ strength_layout.addWidget(self.strength_label)
+
+ self.strength_meter = QProgressBar()
+ self.strength_meter.setFixedHeight(8)
+ self.strength_meter.setTextVisible(False)
+ self.strength_meter.setRange(0, 100)
+ self.strength_meter.setValue(0)
+ self.strength_meter.setStyleSheet("""
+ QProgressBar {
+ border: none;
+ background-color: #2b2b2b;
+ border-radius: 4px;
+ }
+ QProgressBar::chunk {
+ background-color: #666666;
+ border-radius: 4px;
+ }
+ """)
+ strength_layout.addWidget(self.strength_meter)
+ pwd_layout.addLayout(strength_layout)
+
+ confirm_label = QLabel("Confirm Password:")
+ confirm_label.setStyleSheet("QLabel { color: #e0e0e0; font-size: 12px; }")
+ pwd_layout.addWidget(confirm_label)
+
+ self.confirm_password_input = QLineEdit()
+ self.confirm_password_input.setEchoMode(QLineEdit.EchoMode.Password)
+ self.confirm_password_input.setPlaceholderText("Confirm new password")
+ self.confirm_password_input.setFixedHeight(38)
+ self.confirm_password_input.setStyleSheet("""
+ QLineEdit {
+ padding: 0 14px;
+ font-size: 13px;
+ border: 2px solid #3a3a3a;
+ border-radius: 6px;
+ background-color: #2b2b2b;
+ color: #ffffff;
+ }
+ QLineEdit:focus {
+ border: 2px solid #d32f2f;
+ background-color: #2e2e2e;
+ }
+ """)
+ pwd_layout.addWidget(self.confirm_password_input)
+
+ pwd_warning = QLabel(
+ "โ ๏ธ Important: Your new password cannot be recovered if forgotten.\n"
+ "Make it strong and memorable, or you'll need a recovery code again."
+ )
+ pwd_warning.setWordWrap(True)
+ pwd_warning.setStyleSheet("""
+ QLabel {
+ font-size: 12px;
+ color: #ff6b6b;
+ padding: 12px;
+ background-color: #3a2a2a;
+ border-radius: 4px;
+ line-height: 1.4;
+ }
+ """)
+ pwd_layout.addWidget(pwd_warning)
+ pwd_layout.addStretch()
+
+ self.tab_widget.addTab(pwd_tab, "2๏ธโฃ New Password")
+
+ # Disable the new password tab initially
+ self.tab_widget.setTabEnabled(1, False)
+
+ content_layout.addWidget(self.tab_widget)
+
+ # Buttons
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
+ cancel_button = QPushButton("Cancel")
+ cancel_button.setFixedSize(120, 36)
+ cancel_button.setStyleSheet("""
+ QPushButton {
+ background-color: #3a3a3a;
+ color: #e0e0e0;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ QPushButton:hover { background-color: #464646; }
+ QPushButton:pressed { background-color: #2e2e2e; }
+ """)
+ cancel_button.clicked.connect(self.reject)
+
+ self.recover_button = QPushButton("Recover")
+ self.recover_button.setFixedSize(120, 36)
+ self.recover_button.setEnabled(False) # Disabled until code verified
+ self.recover_button.setStyleSheet("""
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ }
+ QPushButton:hover:enabled { background-color: #b71c1c; }
+ QPushButton:pressed:enabled { background-color: #9a0007; }
+ QPushButton:disabled {
+ background-color: #555555;
+ color: #888888;
+ }
+ """)
+ self.recover_button.clicked.connect(self.on_recover)
+
+ button_layout.addStretch()
+ button_layout.addWidget(cancel_button)
+ button_layout.addWidget(self.recover_button)
+ button_layout.addStretch()
+
+ content_layout.addLayout(button_layout)
+
+ main_layout.addWidget(content_frame)
+ self.setLayout(main_layout)
+
+ # Set dynamic size
+ self.setMinimumSize(550, 450)
+ self.resize(550, 520) # Slightly taller for password strength meter
+
+ # Center on screen
+ self.center_on_screen()
+
+ # Focus on recovery code input
+ self.recovery_code_input.setFocus()
+
+ def center_on_screen(self):
+ """Center dialog on screen"""
+ from PyQt6.QtWidgets import QApplication
+ screen = QApplication.primaryScreen()
+ if screen:
+ screen_geometry = screen.geometry()
+ x = (screen_geometry.width() - self.width()) // 2
+ y = (screen_geometry.height() - self.height()) // 2
+ self.move(x, y)
+
+ def on_code_enter_pressed(self):
+ """Handle Enter key or Next button in recovery code tab"""
+ code = self.recovery_code_input.text().strip()
+
+ if not code:
+ self.show_error("Recovery Code Required", "Please enter your recovery code first")
+ return
+
+ # Verify the recovery code if callback provided
+ if self.verify_callback:
+ is_valid, error_msg = self.verify_callback(code)
+ if not is_valid:
+ # Determine appropriate title based on error message
+ if error_msg and "already been used" in error_msg:
+ title = "Recovery Code Already Used"
+ elif error_msg and ("not found" in error_msg or "incorrect" in error_msg):
+ title = "Invalid Recovery Code"
+ elif error_msg and "format" in error_msg:
+ title = "Invalid Code Format"
+ else:
+ title = "Recovery Code Error"
+
+ self.show_error(
+ title,
+ error_msg or "The recovery code you entered is invalid or has already been used.\n"
+ "Please check your codes and try again."
+ )
+ return
+
+ # Code verified - enable new password tab and switch to it
+ self.code_verified = True
+ self.tab_widget.setTabEnabled(1, True)
+ self.tab_widget.setCurrentIndex(1)
+
+ # Enable recover button
+ self.recover_button.setEnabled(True)
+
+ # Focus on new password input
+ self.new_password_input.setFocus()
+
+ def update_password_strength(self):
+ """Update password strength meter based on password input"""
+ password = self.new_password_input.text()
+
+ # Calculate strength
+ strength, color, text = self.calculate_password_strength(password)
+
+ # Update meter
+ self.strength_meter.setValue(strength)
+ self.strength_meter.setStyleSheet(f"""
+ QProgressBar {{
+ border: none;
+ background-color: #2b2b2b;
+ border-radius: 4px;
+ }}
+ QProgressBar::chunk {{
+ background-color: {color};
+ border-radius: 4px;
+ }}
+ """)
+
+ # Update label
+ self.strength_label.setText(f"Password Strength: {text}")
+ self.strength_label.setStyleSheet(f"""
+ QLabel {{
+ font-size: 11px;
+ color: {color};
+ border: none;
+ }}
+ """)
+
+ def calculate_password_strength(self, password):
+ """
+ Calculate password strength.
+ Returns (strength_percent, color, text)
+ """
+ if not password:
+ return (0, "#666666", "-")
+
+ strength = 0
+
+ # Length
+ length = len(password)
+ if length >= 8:
+ strength += 20
+ if length >= 12:
+ strength += 15
+ if length >= 16:
+ strength += 10
+
+ # Character variety
+ has_lower = any(c.islower() for c in password)
+ has_upper = any(c.isupper() for c in password)
+ has_digit = any(c.isdigit() for c in password)
+ has_special = any(not c.isalnum() for c in password)
+
+ variety = sum([has_lower, has_upper, has_digit, has_special])
+ strength += variety * 10
+
+ # Bonus for length + variety
+ if length >= 12 and variety >= 3:
+ strength += 15
+
+ # Cap at 100
+ strength = min(strength, 100)
+
+ # Determine level and color
+ if strength < 20:
+ return (strength, "#f44336", "Very Weak") # Red
+ elif strength < 40:
+ return (strength, "#ff9800", "Weak") # Orange
+ elif strength < 60:
+ return (strength, "#ffeb3b", "Fair") # Yellow
+ elif strength < 80:
+ return (strength, "#8bc34a", "Good") # Light Green
+ else:
+ return (strength, "#4caf50", "Strong") # Green
+
+ def on_recover(self):
+ """Handle recovery button - validate password and proceed"""
+ code = self.recovery_code_input.text().strip()
+ pwd1 = self.new_password_input.text()
+ pwd2 = self.confirm_password_input.text()
+
+ # Validate code
+ if not code:
+ self.show_error("Recovery Code Required", "Please enter your recovery code in the first tab")
+ return
+
+ # Validate passwords
+ if not pwd1:
+ self.show_error("Password Required", "Please enter a new password")
+ return
+
+ if pwd1 != pwd2:
+ self.show_error("Passwords Don't Match", "The passwords you entered do not match")
+ return
+
+ # NO PASSWORD RESTRICTIONS - user can set any password
+ # Just warn if very weak
+ strength, _, strength_text = self.calculate_password_strength(pwd1)
+ if strength < 20:
+ # Warn but allow
+ reply = self.show_confirmation(
+ "Weak Password Warning",
+ f"โ ๏ธ Your password is {strength_text}.\n\n"
+ "Are you sure you want to use this password?\n"
+ "We recommend using a stronger password for better security."
+ )
+ if not reply:
+ return # User cancelled
+
+ # Store values for parent to use
+ self.recovery_code_value = code
+ self.new_password_value = pwd1
+ self.accept()
+
+ def show_error(self, title, message):
+ """Show error message"""
+ msg_box = QMessageBox(self)
+ msg_box.setWindowTitle(title)
+ msg_box.setText(message)
+ msg_box.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; font-size: 12px; }
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ border: none;
+ padding: 5px 20px;
+ border-radius: 3px;
+ }
+ QPushButton:hover { background-color: #b71c1c; }
+ """)
+ msg_box.exec()
+
+ def show_success(self, title, message):
+ """Show success message"""
+ msg_box = QMessageBox(self)
+ msg_box.setWindowTitle(title)
+ msg_box.setText(message)
+ msg_box.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; font-size: 12px; }
+ QPushButton {
+ background-color: #4caf50;
+ color: white;
+ border: none;
+ padding: 5px 20px;
+ border-radius: 3px;
+ }
+ QPushButton:hover { background-color: #388e3c; }
+ """)
+ msg_box.exec()
+
+ def show_confirmation(self, title, message):
+ """Show confirmation dialog - returns True if user confirms"""
+ msg_box = QMessageBox(self)
+ msg_box.setWindowTitle(title)
+ msg_box.setText(message)
+ msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
+ msg_box.setDefaultButton(QMessageBox.StandardButton.No)
+ msg_box.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; font-size: 12px; }
+ QPushButton {
+ background-color: #3a3a3a;
+ color: white;
+ border: none;
+ padding: 5px 20px;
+ border-radius: 3px;
+ min-width: 60px;
+ }
+ QPushButton:hover { background-color: #464646; }
+ """)
+ return msg_box.exec() == QMessageBox.StandardButton.Yes
+
+ def get_recovery_code(self):
+ """Get entered recovery code"""
+ return self.recovery_code_value
+
+ def get_new_password(self):
+ """Get new password"""
+ return self.new_password_value
+
+ def keyPressEvent(self, event):
+ """Handle key press"""
+ if event.key() == Qt.Key.Key_Escape:
+ self.reject()
+ else:
+ super().keyPressEvent(event)
+
+
+class RecoveryCodesDisplayDialog(QDialog):
+ """Dialog to display generated recovery codes to user"""
+
+ def __init__(self, codes: list, resource_path=None, parent=None):
+ super().__init__(parent)
+ self.codes = codes
+ self.resource_path = resource_path
+ self.saved_checkbox = None
+ self.confirm_button = None
+
+ self.setWindowTitle("Recovery Codes - Save These!")
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize UI"""
+ self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowStaysOnTopHint)
+ self.setStyleSheet("QDialog { background-color: #1a1a1a; }")
+ self.setMinimumSize(600, 550)
+
+ main_layout = QVBoxLayout()
+ main_layout.setContentsMargins(20, 20, 20, 20)
+ main_layout.setSpacing(15)
+
+ # Warning title
+ warning_title = QLabel("๐ SAVE YOUR RECOVERY CODES")
+ warning_title.setFont(QFont("Arial", 14, QFont.Weight.Bold))
+ warning_title.setStyleSheet("QLabel { color: #ff6b6b; }")
+ warning_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ main_layout.addWidget(warning_title)
+
+ # Important message
+ message = QLabel(
+ "If you forget your FadCrypt master password, you can use these codes to recover access.\n\n"
+ "โ ๏ธ IMPORTANT:\n"
+ "โข Each code can ONLY be used ONCE\n"
+ "โข Save these codes in a SAFE PLACE (print, password manager, etc.)\n"
+ "โข Do NOT share these codes with anyone\n"
+ "โข If all codes are used and you forget your password, you will lose access\n"
+ )
+ message.setWordWrap(True)
+ message.setStyleSheet("""
+ QLabel {
+ color: #e0e0e0;
+ padding: 15px;
+ background-color: #2a2a2a;
+ border-radius: 5px;
+ border-left: 4px solid #ff6b6b;
+ }
+ """)
+ main_layout.addWidget(message)
+
+ # Codes display area
+ scroll = QScrollArea()
+ scroll.setStyleSheet("""
+ QScrollArea {
+ background-color: #1e1e1e;
+ border: 2px solid #3a3a3a;
+ border-radius: 5px;
+ }
+ QScrollBar:vertical {
+ background-color: #2a2a2a;
+ width: 12px;
+ border-radius: 6px;
+ }
+ QScrollBar::handle:vertical {
+ background-color: #555555;
+ border-radius: 6px;
+ }
+ """)
+ scroll.setWidgetResizable(True)
+
+ codes_container = QFrame()
+ codes_layout = QVBoxLayout(codes_container)
+ codes_layout.setContentsMargins(15, 15, 15, 15)
+ codes_layout.setSpacing(10)
+
+ for i, code in enumerate(self.codes, 1):
+ code_label = QLabel(f"{i:2d}. {code}")
+ code_label.setFont(QFont("Courier New", 11, QFont.Weight.Bold))
+ code_label.setStyleSheet("""
+ QLabel {
+ color: #00ff00;
+ padding: 8px;
+ background-color: #1a1a1a;
+ border: 1px solid #004400;
+ border-radius: 3px;
+ font-family: monospace;
+ }
+ """)
+ codes_layout.addWidget(code_label)
+
+ codes_layout.addStretch()
+ scroll.setWidget(codes_container)
+ main_layout.addWidget(scroll)
+
+ # Checkbox confirmation
+ self.saved_checkbox = QCheckBox("โ I have safely saved these codes")
+ self.saved_checkbox.setStyleSheet("""
+ QCheckBox {
+ color: #e0e0e0;
+ font-size: 13px;
+ font-weight: 600;
+ spacing: 8px;
+ }
+ QCheckBox::indicator {
+ width: 20px;
+ height: 20px;
+ border: 2px solid #555555;
+ border-radius: 4px;
+ background-color: #2a2a2a;
+ }
+ QCheckBox::indicator:checked {
+ background-color: #4caf50;
+ border-color: #4caf50;
+ }
+ QCheckBox::indicator:hover {
+ border-color: #777777;
+ }
+ """)
+ self.saved_checkbox.stateChanged.connect(self.on_checkbox_changed)
+ main_layout.addWidget(self.saved_checkbox)
+
+ # Action buttons
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
+ save_file_button = QPushButton("๐พ Save to File")
+ save_file_button.setFixedHeight(36)
+ save_file_button.setStyleSheet("""
+ QPushButton {
+ background-color: #1976d2;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-weight: 600;
+ padding: 0 15px;
+ }
+ QPushButton:hover { background-color: #1565c0; }
+ QPushButton:pressed { background-color: #0d47a1; }
+ """)
+ save_file_button.clicked.connect(self.save_to_file)
+ button_layout.addWidget(save_file_button)
+
+ copy_button = QPushButton("๐ Copy All")
+ copy_button.setFixedHeight(36)
+ copy_button.setStyleSheet("""
+ QPushButton {
+ background-color: #1a4620;
+ color: #00ff00;
+ border: 1px solid #004400;
+ border-radius: 6px;
+ font-weight: 600;
+ }
+ QPushButton:hover { background-color: #215028; }
+ """)
+ copy_button.clicked.connect(self.copy_codes)
+ button_layout.addWidget(copy_button)
+
+ button_layout.addStretch()
+
+ self.confirm_button = QPushButton("I Have Saved These Codes")
+ self.confirm_button.setFixedHeight(36)
+ self.confirm_button.setEnabled(False) # Disabled until checkbox checked
+ self.confirm_button.setStyleSheet("""
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ border: none;
+ border-radius: 6px;
+ font-weight: 600;
+ padding: 0 20px;
+ }
+ QPushButton:hover:enabled { background-color: #b71c1c; }
+ QPushButton:pressed:enabled { background-color: #9a0007; }
+ QPushButton:disabled {
+ background-color: #555555;
+ color: #888888;
+ }
+ """)
+ self.confirm_button.clicked.connect(self.accept)
+ button_layout.addWidget(self.confirm_button)
+
+ main_layout.addLayout(button_layout)
+
+ self.setLayout(main_layout)
+
+ # Center on screen
+ from PyQt6.QtWidgets import QApplication
+ screen = QApplication.primaryScreen()
+ if screen:
+ screen_geometry = screen.geometry()
+ x = (screen_geometry.width() - self.width()) // 2
+ y = (screen_geometry.height() - self.height()) // 2
+ self.move(x, y)
+
+ def on_checkbox_changed(self, state):
+ """Enable/disable confirm button based on checkbox state"""
+ self.confirm_button.setEnabled(state == Qt.CheckState.Checked.value)
+
+ def save_to_file(self):
+ """Save recovery codes to a text file"""
+ # Default filename
+ default_filename = "fadcrypt_recovery_codes.txt"
+
+ # Open file dialog to choose save location
+ file_path, _ = QFileDialog.getSaveFileName(
+ self,
+ "Save Recovery Codes",
+ os.path.expanduser(f"~/{default_filename}"),
+ "Text Files (*.txt);;All Files (*)"
+ )
+
+ if file_path:
+ try:
+ # Format codes for file
+ content = "FadCrypt Password Recovery Codes\n"
+ content += "=" * 50 + "\n\n"
+ content += "โ ๏ธ IMPORTANT: Keep these codes safe!\n"
+ content += "โข Each code can only be used ONCE\n"
+ content += "โข You need these if you forget your master password\n"
+ content += "โข Do NOT share these codes with anyone\n\n"
+ content += "=" * 50 + "\n\n"
+
+ for i, code in enumerate(self.codes, 1):
+ content += f"{i:2d}. {code}\n"
+
+ content += "\n" + "=" * 50 + "\n"
+ content += f"Generated: {self._get_timestamp()}\n"
+
+ # Write to file
+ with open(file_path, 'w') as f:
+ f.write(content)
+
+ # Show success message
+ msg = QMessageBox(self)
+ msg.setWindowTitle("Saved")
+ msg.setText(f"โ
Recovery codes saved to:\n{file_path}")
+ msg.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; }
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ padding: 5px 20px;
+ border: none;
+ border-radius: 3px;
+ }
+ QPushButton:hover { background-color: #b71c1c; }
+ """)
+ msg.exec()
+
+ except Exception as e:
+ # Show error message
+ msg = QMessageBox(self)
+ msg.setWindowTitle("Error")
+ msg.setText(f"โ Failed to save file:\n{str(e)}")
+ msg.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; }
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ padding: 5px 20px;
+ border: none;
+ border-radius: 3px;
+ }
+ """)
+ msg.exec()
+
+ def _get_timestamp(self):
+ """Get current timestamp for file"""
+ from datetime import datetime
+ return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+
+ def copy_codes(self):
+ """Copy all codes to clipboard"""
+ from PyQt6.QtWidgets import QApplication
+
+ text = "FadCrypt Recovery Codes:\n\n"
+ for i, code in enumerate(self.codes, 1):
+ text += f"{i:2d}. {code}\n"
+
+ clipboard = QApplication.clipboard()
+ if clipboard:
+ clipboard.setText(text)
+
+ msg = QMessageBox(self)
+ msg.setWindowTitle("Copied")
+ msg.setText("โ
All recovery codes copied to clipboard!")
+ msg.setStyleSheet("""
+ QMessageBox { background-color: #1e1e1e; }
+ QMessageBox QLabel { color: #e0e0e0; }
+ QPushButton {
+ background-color: #d32f2f;
+ color: white;
+ padding: 5px 20px;
+ border: none;
+ border-radius: 3px;
+ }
+ QPushButton:hover { background-color: #b71c1c; }
+ """)
+ msg.exec()
+
+
+def ask_recovery_code(resource_path=None, parent=None, verify_callback=None):
+ """
+ Show recovery code dialog.
+
+ Args:
+ resource_path: Path to resources
+ parent: Parent widget
+ verify_callback: Callback function to verify recovery code
+ Should accept (code: str) and return (is_valid: bool, error_msg: Optional[str])
+
+ Returns:
+ Tuple of (recovery_code: str, new_password: str) or (None, None) if cancelled
+ """
+ dialog = RecoveryCodeDialog("Recover Access with Recovery Code", resource_path, parent, verify_callback)
+
+ if dialog.exec() == QDialog.DialogCode.Accepted:
+ return dialog.get_recovery_code(), dialog.get_new_password()
+ return None, None
+
+
+def show_recovery_codes(codes: list, resource_path=None, parent=None):
+ """
+ Show generated recovery codes to user.
+ Blocks until user confirms they have saved the codes.
+ """
+ dialog = RecoveryCodesDisplayDialog(codes, resource_path, parent)
+ dialog.exec()