From f67b73f9147f0edf0537a254a5d7a74f574aeafa Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 1 Jul 2025 21:32:34 -0400 Subject: [PATCH] Add portable export/import features --- src/main.py | 37 +++++++++++++-- src/password_manager/manager.py | 34 ++++++++++++++ .../test_cli_portable_backup_commands.py | 47 +++++++++++++++++++ 3 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 src/tests/test_cli_portable_backup_commands.py diff --git a/src/main.py b/src/main.py index 566ef2a..0c7a03b 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ from termcolor import colored import traceback from password_manager.manager import PasswordManager +from password_manager.portable_backup import PortableMode from nostr.client import NostrClient from constants import INACTIVITY_TIMEOUT from utils.key_derivation import EncryptionMode @@ -457,8 +458,10 @@ def handle_settings(password_manager: PasswordManager) -> None: print("3. Change password") print("4. Verify Script Checksum") print("5. Backup Parent Seed") - print("6. Lock Vault") - print("7. Back") + print("6. Export database") + print("7. Import database") + print("8. Lock Vault") + print("9. Back") choice = input("Select an option: ").strip() if choice == "1": handle_profiles_menu(password_manager) @@ -471,10 +474,16 @@ def handle_settings(password_manager: PasswordManager) -> None: elif choice == "5": password_manager.handle_backup_reveal_parent_seed() elif choice == "6": + password_manager.handle_export_database() + elif choice == "7": + path = input("Enter path to backup file: ").strip() + if path: + password_manager.handle_import_database(Path(path)) + elif choice == "8": password_manager.lock_vault() print(colored("Vault locked. Please re-enter your password.", "yellow")) password_manager.unlock_vault() - elif choice == "7": + elif choice == "9": break else: print(colored("Invalid choice.", "red")) @@ -565,11 +574,25 @@ if __name__ == "__main__": # Load config from disk and parse command-line arguments cfg = load_global_config() parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command") + parser.add_argument( "--encryption-mode", choices=[m.value for m in EncryptionMode], help="Select encryption mode", ) + + exp = sub.add_parser("export") + exp.add_argument( + "--mode", + choices=[m.value for m in PortableMode], + default=PortableMode.SEED_ONLY.value, + ) + exp.add_argument("--file") + + imp = sub.add_parser("import") + imp.add_argument("--file") + args = parser.parse_args() mode_value = cfg.get("encryption_mode", EncryptionMode.SEED_ONLY.value) @@ -591,6 +614,14 @@ if __name__ == "__main__": print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) sys.exit(1) + if args.command == "export": + mode = PortableMode(args.mode) + password_manager.handle_export_database(mode, Path(args.file)) + sys.exit(0) + elif args.command == "import": + password_manager.handle_import_database(Path(args.file)) + sys.exit(0) + # Register signal handlers for graceful shutdown def signal_handler(sig, frame): """ diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 1f6f5dc..e2e6b52 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -24,6 +24,11 @@ from password_manager.entry_management import EntryManager from password_manager.password_generation import PasswordGenerator from password_manager.backup import BackupManager from password_manager.vault import Vault +from password_manager.portable_backup import ( + export_backup, + import_backup, + PortableMode, +) from utils.key_derivation import ( derive_key_from_parent_seed, derive_key_from_password, @@ -1137,6 +1142,35 @@ class PasswordManager: logging.error(f"Failed to restore backup: {e}", exc_info=True) print(colored(f"Error: Failed to restore backup: {e}", "red")) + def handle_export_database( + self, + mode: "PortableMode" = PortableMode.SEED_ONLY, + dest: Path | None = None, + ) -> Path | None: + """Export the current database to an encrypted portable file.""" + try: + path = export_backup( + self.vault, + self.backup_manager, + mode, + dest, + ) + print(colored(f"Database exported to '{path}'.", "green")) + return path + except Exception as e: + logging.error(f"Failed to export database: {e}", exc_info=True) + print(colored(f"Error: Failed to export database: {e}", "red")) + return None + + def handle_import_database(self, src: Path) -> None: + """Import a portable database file, replacing the current index.""" + try: + import_backup(self.vault, self.backup_manager, src) + print(colored("Database imported successfully.", "green")) + except Exception as e: + logging.error(f"Failed to import database: {e}", exc_info=True) + print(colored(f"Error: Failed to import database: {e}", "red")) + def handle_backup_reveal_parent_seed(self) -> None: """ Handles the backup and reveal of the parent seed. diff --git a/src/tests/test_cli_portable_backup_commands.py b/src/tests/test_cli_portable_backup_commands.py new file mode 100644 index 0000000..fa6c86c --- /dev/null +++ b/src/tests/test_cli_portable_backup_commands.py @@ -0,0 +1,47 @@ +import sys +from pathlib import Path +import runpy + +import pytest + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import main +from password_manager.portable_backup import PortableMode +from password_manager.manager import PasswordManager + + +def _run(argv, monkeypatch): + monkeypatch.setattr(sys, "argv", ["seedpass"] + argv) + monkeypatch.setattr(main, "load_global_config", lambda: {}) + called = {} + + def fake_init(self, encryption_mode): + called["init"] = True + + def fake_export(self, mode, dest): + called["export"] = (mode, Path(dest)) + + def fake_import(self, src): + called["import"] = Path(src) + + monkeypatch.setattr(PasswordManager, "__init__", fake_init) + monkeypatch.setattr(PasswordManager, "handle_export_database", fake_export) + monkeypatch.setattr(PasswordManager, "handle_import_database", fake_import) + + with pytest.raises(SystemExit): + runpy.run_module("main", run_name="__main__") + + return called + + +def test_export_command_invokes_handler(monkeypatch): + called = _run(["export", "--mode", "pw-only", "--file", "out.json"], monkeypatch) + assert called["export"] == (PortableMode.PW_ONLY, Path("out.json")) + assert "import" not in called + + +def test_import_command_invokes_handler(monkeypatch): + called = _run(["import", "--file", "backup.json"], monkeypatch) + assert called["import"] == Path("backup.json") + assert "export" not in called