From b3047484df9e3fd997cd07ed4e8dbbde83b80c83 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 4 Jul 2025 12:38:56 -0400 Subject: [PATCH] Automate script checksum handling --- README.md | 23 +++++------ scripts/update_checksum.py | 9 ++--- src/main.py | 41 ++++++++++--------- src/password_manager/manager.py | 48 ++++++++++++++++++++++- src/tests/test_manager_checksum_backup.py | 2 +- src/tests/test_settings_menu.py | 2 +- src/utils/__init__.py | 4 ++ src/utils/checksum.py | 26 ++++++++++++ 8 files changed, 115 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 9cc5e96..0ad7a33 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - **Encrypted Storage:** All seeds, login passwords, and sensitive index data are encrypted locally. - **Nostr Integration:** Post and retrieve your encrypted password index to/from the Nostr network. - **Chunked Snapshots:** Encrypted vaults are compressed and split into 50 KB chunks published as `kind 30071` events with a `kind 30070` manifest and `kind 30072` deltas. -- **Checksum Verification:** Ensure the integrity of the script with checksum verification. +- **Automatic Checksum Generation:** The script generates and verifies a SHA-256 checksum to detect tampering. - **Multiple Seed Profiles:** Manage separate seed profiles and switch between them seamlessly. - **Interactive TUI:** Navigate through menus to add, retrieve, and modify entries as well as configure Nostr settings. - **SeedPass 2FA:** Generate TOTP codes with a real-time countdown progress bar. @@ -279,16 +279,17 @@ Back in the Settings menu you can: * Select `3` to change your master password. * Choose `4` to verify the script checksum. -* Choose `5` to back up the parent seed. -* Select `6` to export the database to an encrypted file. -* Choose `7` to import a database from a backup file. -* Select `8` to export all 2FA codes. -* Choose `9` to set an additional backup location. -* Select `10` to change the inactivity timeout. -* Choose `11` to toggle Secret Mode and set the clipboard clear delay. -* Select `12` to lock the vault and require re-entry of your password. -* Choose `13` to return to the main menu. -* Select `14` to view seed profile stats. +* Select `5` to generate a new script checksum. +* Choose `6` to back up the parent seed. +* Select `7` to export the database to an encrypted file. +* Choose `8` to import a database from a backup file. +* Select `9` to export all 2FA codes. +* Choose `10` to set an additional backup location. +* Select `11` to change the inactivity timeout. +* Choose `12` to lock the vault and require re-entry of your password. +* Select `13` to view seed profile stats. +* Choose `14` to toggle Secret Mode and set the clipboard clear delay. +* Select `15` to return to the main menu. ## Running Tests diff --git a/scripts/update_checksum.py b/scripts/update_checksum.py index aa9f8fc..537c415 100644 --- a/scripts/update_checksum.py +++ b/scripts/update_checksum.py @@ -7,7 +7,7 @@ SRC_DIR = PROJECT_ROOT / "src" if str(SRC_DIR) not in sys.path: sys.path.insert(0, str(SRC_DIR)) -from utils.checksum import calculate_checksum +from utils.checksum import update_checksum_file from constants import SCRIPT_CHECKSUM_FILE, initialize_app @@ -15,11 +15,8 @@ def main() -> None: """Calculate checksum for the main script and write it to SCRIPT_CHECKSUM_FILE.""" initialize_app() script_path = SRC_DIR / "password_manager" / "manager.py" - checksum = calculate_checksum(str(script_path)) - if checksum is None: - raise SystemExit(f"Failed to calculate checksum for {script_path}") - - SCRIPT_CHECKSUM_FILE.write_text(checksum) + if not update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): + raise SystemExit(f"Failed to update checksum for {script_path}") print(f"Updated checksum written to {SCRIPT_CHECKSUM_FILE}") diff --git a/src/main.py b/src/main.py index 011255a..f8bf76c 100644 --- a/src/main.py +++ b/src/main.py @@ -617,16 +617,17 @@ def handle_settings(password_manager: PasswordManager) -> None: print("2. Nostr") print("3. Change password") print("4. Verify Script Checksum") - print("5. Backup Parent Seed") - print("6. Export database") - print("7. Import database") - print("8. Export 2FA codes") - print("9. Set additional backup location") - print("10. Set inactivity timeout") - print("11. Lock Vault") - print("12. Stats") - print("13. Toggle Secret Mode") - print("14. Back") + print("5. Generate Script Checksum") + print("6. Backup Parent Seed") + print("7. Export database") + print("8. Import database") + print("9. Export 2FA codes") + print("10. Set additional backup location") + print("11. Set inactivity timeout") + print("12. Lock Vault") + print("13. Stats") + print("14. Toggle Secret Mode") + print("15. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -637,28 +638,30 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "4": password_manager.handle_verify_checksum() elif choice == "5": - password_manager.handle_backup_reveal_parent_seed() + password_manager.handle_update_script_checksum() elif choice == "6": - password_manager.handle_export_database() + password_manager.handle_backup_reveal_parent_seed() elif choice == "7": + password_manager.handle_export_database() + elif choice == "8": path = input("Enter path to backup file: ").strip() if path: password_manager.handle_import_database(Path(path)) - elif choice == "8": - password_manager.handle_export_totp_codes() elif choice == "9": - handle_set_additional_backup_location(password_manager) + password_manager.handle_export_totp_codes() elif choice == "10": - handle_set_inactivity_timeout(password_manager) + handle_set_additional_backup_location(password_manager) elif choice == "11": + handle_set_inactivity_timeout(password_manager) + elif choice == "12": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "12": - handle_display_stats(password_manager) elif choice == "13": - handle_toggle_secret_mode(password_manager) + handle_display_stats(password_manager) elif choice == "14": + handle_toggle_secret_mode(password_manager) + elif choice == "15": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index e6327d5..2ef01e1 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -34,7 +34,13 @@ from utils.key_derivation import ( derive_index_key, EncryptionMode, ) -from utils.checksum import calculate_checksum, verify_checksum, json_checksum +from utils.checksum import ( + calculate_checksum, + verify_checksum, + json_checksum, + initialize_checksum, + update_checksum_file, +) from utils.password_prompt import ( prompt_for_password, prompt_existing_password, @@ -89,6 +95,7 @@ class PasswordManager: def __init__(self) -> None: """Initialize the PasswordManager.""" initialize_app() + self.ensure_script_checksum() self.encryption_mode: EncryptionMode = EncryptionMode.SEED_ONLY self.encryption_manager: Optional[EncryptionManager] = None self.entry_manager: Optional[EntryManager] = None @@ -119,6 +126,23 @@ class PasswordManager: # Set the current fingerprint directory self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() + def ensure_script_checksum(self) -> None: + """Initialize or verify the checksum of the manager script.""" + script_path = Path(__file__).resolve() + if not SCRIPT_CHECKSUM_FILE.exists(): + initialize_checksum(str(script_path), SCRIPT_CHECKSUM_FILE) + return + checksum = calculate_checksum(str(script_path)) + if checksum and not verify_checksum(checksum, SCRIPT_CHECKSUM_FILE): + logging.warning("Script checksum mismatch detected on startup") + print( + colored( + "Warning: script checksum mismatch. " + "Run 'Generate Script Checksum' in Settings if you've updated the app.", + "red", + ) + ) + @property def parent_seed(self) -> Optional[str]: """Return the decrypted parent seed if set.""" @@ -1473,7 +1497,7 @@ class PasswordManager: except FileNotFoundError: print( colored( - "Checksum file missing. Run scripts/update_checksum.py to generate it.", + "Checksum file missing. Run scripts/update_checksum.py or choose 'Generate Script Checksum' in Settings.", "yellow", ) ) @@ -1495,6 +1519,26 @@ class PasswordManager: logging.error(f"Error during checksum verification: {e}", exc_info=True) print(colored(f"Error: Failed to verify checksum: {e}", "red")) + def handle_update_script_checksum(self) -> None: + """Generate a new checksum for the manager script.""" + if not confirm_action("Generate new script checksum? (Y/N): "): + print(colored("Operation cancelled.", "yellow")) + return + try: + script_path = Path(__file__).resolve() + if update_checksum_file(str(script_path), str(SCRIPT_CHECKSUM_FILE)): + print( + colored( + f"Checksum updated at '{SCRIPT_CHECKSUM_FILE}'.", + "green", + ) + ) + else: + print(colored("Failed to update checksum.", "red")) + except Exception as e: + logging.error(f"Error updating checksum: {e}", exc_info=True) + print(colored(f"Error: Failed to update checksum: {e}", "red")) + def get_encrypted_data(self) -> Optional[bytes]: """ Retrieves the encrypted password index data. diff --git a/src/tests/test_manager_checksum_backup.py b/src/tests/test_manager_checksum_backup.py index 5b15bae..4a74ba0 100644 --- a/src/tests/test_manager_checksum_backup.py +++ b/src/tests/test_manager_checksum_backup.py @@ -57,7 +57,7 @@ def test_handle_verify_checksum_missing(monkeypatch, tmp_path, capsys): monkeypatch.setattr("password_manager.manager.verify_checksum", raise_missing) pm.handle_verify_checksum() out = capsys.readouterr().out.lower() - assert "update_checksum.py" in out + assert "generate script checksum" in out def test_backup_and_restore_database(monkeypatch, capsys): diff --git a/src/tests/test_settings_menu.py b/src/tests/test_settings_menu.py index 1ef8a25..bd8a6f0 100644 --- a/src/tests/test_settings_menu.py +++ b/src/tests/test_settings_menu.py @@ -93,7 +93,7 @@ def test_settings_menu_additional_backup(monkeypatch): tmp_path = Path(tmpdir) pm, cfg_mgr, fp_mgr = setup_pm(tmp_path, monkeypatch) - inputs = iter(["9", "14"]) + inputs = iter(["10", "15"]) with patch("main.handle_set_additional_backup_location") as handler: with patch("builtins.input", side_effect=lambda *_: next(inputs)): main.handle_settings(pm) diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 2ff4329..286d641 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -21,6 +21,8 @@ try: verify_checksum, json_checksum, canonical_json_dumps, + initialize_checksum, + update_checksum_file, ) from .password_prompt import prompt_for_password from .input_utils import timed_input @@ -45,6 +47,8 @@ __all__ = [ "verify_checksum", "json_checksum", "canonical_json_dumps", + "initialize_checksum", + "update_checksum_file", "exclusive_lock", "shared_lock", "prompt_for_password", diff --git a/src/utils/checksum.py b/src/utils/checksum.py index 3af6a9c..84aa8c3 100644 --- a/src/utils/checksum.py +++ b/src/utils/checksum.py @@ -198,3 +198,29 @@ def initialize_checksum(file_path: str, checksum_file_path: str) -> bool: ) ) return False + + +def update_checksum_file(file_path: str, checksum_file_path: str) -> bool: + """Update ``checksum_file_path`` with the SHA-256 checksum of ``file_path``.""" + checksum = calculate_checksum(file_path) + if checksum is None: + return False + try: + with open(checksum_file_path, "w") as f: + f.write(checksum) + logging.debug( + f"Updated checksum for '{file_path}' to '{checksum}' at '{checksum_file_path}'." + ) + return True + except Exception as exc: + logging.error( + f"Failed to update checksum file '{checksum_file_path}': {exc}", + exc_info=True, + ) + print( + colored( + f"Error: Failed to update checksum file '{checksum_file_path}': {exc}", + "red", + ) + ) + return False