diff --git a/src/main.py b/src/main.py index 9717f82..8d6e0eb 100644 --- a/src/main.py +++ b/src/main.py @@ -1309,6 +1309,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in exp = sub.add_parser("export") exp.add_argument("--file") + exp.add_argument( + "--unencrypted", + action="store_true", + help="Export without encryption", + ) imp = sub.add_parser("import") imp.add_argument("--file") @@ -1380,7 +1385,9 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in password_manager.deterministic_totp = True if args.command == "export": - password_manager.handle_export_database(Path(args.file)) + password_manager.handle_export_database( + Path(args.file), encrypt=not args.unencrypted + ) return 0 if args.command == "import": password_manager.handle_import_database(Path(args.file)) diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 4080ebc..2515a24 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -36,7 +36,7 @@ from .entry_management import EntryManager from .password_generation import PasswordGenerator from .backup import BackupManager from .vault import Vault -from .portable_backup import export_backup, import_backup +from .portable_backup import export_backup, import_backup, PortableMode from cryptography.fernet import InvalidToken from .totp import TotpManager from .entry_types import EntryType @@ -4102,8 +4102,15 @@ class PasswordManager: def handle_export_database( self, dest: Path | None = None, + *, + encrypt: bool | None = None, ) -> Path | None: - """Export the current database to an encrypted portable file.""" + """Export the current database to a portable file. + + If ``encrypt`` is ``True`` (default) the payload is encrypted. When + ``encrypt`` is ``False`` the export contains plaintext data. When + ``encrypt`` is ``None`` the user is prompted interactively. + """ try: fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( @@ -4113,11 +4120,16 @@ class PasswordManager: parent_fingerprint=parent_fp, child_fingerprint=child_fp, ) + if encrypt is None: + encrypt = not confirm_action( + "Export database without encryption? (Y/N): " + ) path = export_backup( self.vault, self.backup_manager, dest, parent_seed=self.parent_seed, + encrypt=encrypt, ) print(colored(f"Database exported to '{path}'.", "green")) audit_logger = getattr(self, "audit_logger", None) @@ -4132,15 +4144,26 @@ class PasswordManager: def handle_import_database(self, src: Path) -> None: """Import a portable database file, replacing the current index.""" - if not src.name.endswith(".json.enc"): + if not (src.name.endswith(".json.enc") or src.name.endswith(".json")): print( colored( - "Error: Selected file must be a SeedPass database backup (.json.enc).", + "Error: Selected file must be a SeedPass database backup (.json or .json.enc).", "red", ) ) return + # Determine encryption mode for post-processing + mode = None + try: + raw = src.read_bytes() + if src.suffix.endswith(".enc"): + raw = self.vault.encryption_manager.decrypt_data(raw, context=str(src)) + wrapper = json.loads(raw.decode("utf-8")) + mode = wrapper.get("encryption_mode") + except Exception: + mode = None + fp, parent_fp, child_fp = self.header_fingerprint_args clear_header_with_notification( self, @@ -4180,6 +4203,23 @@ class PasswordManager: ) return + if mode == PortableMode.NONE.value: + try: + password = prompt_new_password() + iterations = self.config_manager.get_kdf_iterations() + seed_key = derive_key_from_password( + password, self.current_fingerprint, iterations=iterations + ) + seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir) + seed_mgr.encrypt_parent_seed(self.parent_seed) + self.store_hashed_password(password) + except Exception as e: + logging.error( + f"Failed to set master password after import: {e}", exc_info=True + ) + print(colored(f"Error: Failed to set master password: {e}", "red")) + return + print(colored("Database imported successfully.", "green")) self.sync_vault() diff --git a/src/seedpass/core/portable_backup.py b/src/seedpass/core/portable_backup.py index 35cb2c2..1f0215b 100644 --- a/src/seedpass/core/portable_backup.py +++ b/src/seedpass/core/portable_backup.py @@ -32,6 +32,7 @@ class PortableMode(Enum): """Encryption mode for portable exports.""" SEED_ONLY = EncryptionMode.SEED_ONLY.value + NONE = "none" def _derive_export_key(seed: str) -> bytes: @@ -47,8 +48,15 @@ def export_backup( *, publish: bool = False, parent_seed: str | None = None, + encrypt: bool = True, ) -> Path: - """Export the current vault state to a portable encrypted file.""" + """Export the current vault state to a portable file. + + When ``encrypt`` is ``True`` (the default) the payload is encrypted with a + key derived from the parent seed. When ``encrypt`` is ``False`` the payload + is written in plaintext and the wrapper records an ``encryption_mode`` of + :data:`PortableMode.NONE`. + """ if dest_path is None: ts = int(time.time()) @@ -57,24 +65,32 @@ def export_backup( dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts) index_data = vault.load_index() - seed = ( - parent_seed - if parent_seed is not None - else vault.encryption_manager.decrypt_parent_seed() - ) - key = _derive_export_key(seed) - enc_mgr = EncryptionManager(key, vault.fingerprint_dir) - canonical = canonical_json_dumps(index_data) - payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8")) + + if encrypt: + seed = ( + parent_seed + if parent_seed is not None + else vault.encryption_manager.decrypt_parent_seed() + ) + key = _derive_export_key(seed) + enc_mgr = EncryptionManager(key, vault.fingerprint_dir) + payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8")) + mode = PortableMode.SEED_ONLY + cipher = "aes-gcm" + else: + payload_bytes = canonical.encode("utf-8") + mode = PortableMode.NONE + cipher = "none" + checksum = json_checksum(index_data) wrapper = { "format_version": FORMAT_VERSION, "created_at": int(time.time()), "fingerprint": vault.fingerprint_dir.name, - "encryption_mode": PortableMode.SEED_ONLY.value, - "cipher": "aes-gcm", + "encryption_mode": mode.value, + "cipher": cipher, "checksum": checksum, "payload": base64.b64encode(payload_bytes).decode("utf-8"), } @@ -118,19 +134,24 @@ def import_backup( if wrapper.get("format_version") != FORMAT_VERSION: raise ValueError("Unsupported backup format") - if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value: - raise ValueError("Unsupported encryption mode") + mode = wrapper.get("encryption_mode") payload = base64.b64decode(wrapper["payload"]) - seed = ( - parent_seed - if parent_seed is not None - else vault.encryption_manager.decrypt_parent_seed() - ) - key = _derive_export_key(seed) - enc_mgr = EncryptionManager(key, vault.fingerprint_dir) - enc_mgr._legacy_migrate_flag = False - index_bytes = enc_mgr.decrypt_data(payload, context="backup payload") + if mode == PortableMode.SEED_ONLY.value: + seed = ( + parent_seed + if parent_seed is not None + else vault.encryption_manager.decrypt_parent_seed() + ) + key = _derive_export_key(seed) + enc_mgr = EncryptionManager(key, vault.fingerprint_dir) + enc_mgr._legacy_migrate_flag = False + index_bytes = enc_mgr.decrypt_data(payload, context="backup payload") + elif mode == PortableMode.NONE.value: + index_bytes = payload + else: + raise ValueError("Unsupported encryption mode") + index = json.loads(index_bytes.decode("utf-8")) checksum = json_checksum(index) diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 0dda092..c450341 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -378,7 +378,7 @@ async def test_vault_export_endpoint(client, tmp_path): out = tmp_path / "out.json" out.write_text("data") - api.app.state.pm.handle_export_database = lambda: out + api.app.state.pm.handle_export_database = lambda *a, **k: out headers = { "Authorization": f"Bearer {token}", diff --git a/src/tests/test_audit_logger.py b/src/tests/test_audit_logger.py index d33e417..884a139 100644 --- a/src/tests/test_audit_logger.py +++ b/src/tests/test_audit_logger.py @@ -36,6 +36,7 @@ def test_audit_logger_records_events(monkeypatch, tmp_path): monkeypatch.setattr(manager_module, "export_backup", lambda *a, **k: dest) pm.vault = object() pm.backup_manager = object() + monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True) pm.handle_export_database(dest) confirms = iter([True, False]) diff --git a/src/tests/test_cli_doc_examples.py b/src/tests/test_cli_doc_examples.py index f4bbd61..8ef0ae6 100644 --- a/src/tests/test_cli_doc_examples.py +++ b/src/tests/test_cli_doc_examples.py @@ -42,7 +42,7 @@ class DummyPM: ) self.parent_seed = "seed" self.handle_display_totp_codes = lambda: None - self.handle_export_database = lambda path: None + self.handle_export_database = lambda path, **kwargs: None self.handle_import_database = lambda path: None self.change_password = lambda *a, **kw: None self.lock_vault = lambda: None diff --git a/src/tests/test_cli_export_import.py b/src/tests/test_cli_export_import.py index e01b38d..7b1a53e 100644 --- a/src/tests/test_cli_export_import.py +++ b/src/tests/test_cli_export_import.py @@ -17,8 +17,8 @@ def _setup_pm(tmp_path: Path): cfg = ConfigManager(vault, tmp_path) backup = BackupManager(tmp_path, cfg) pm = SimpleNamespace( - handle_export_database=lambda p: export_backup( - vault, backup, p, parent_seed=TEST_SEED + handle_export_database=lambda p, encrypt=True: export_backup( + vault, backup, p, parent_seed=TEST_SEED, encrypt=encrypt ), handle_import_database=lambda p: import_backup( vault, backup, p, parent_seed=TEST_SEED @@ -91,3 +91,36 @@ def test_cli_import_round_trip(monkeypatch, tmp_path): rc = main.main(["import", "--file", str(export_path)]) assert rc == 0 assert vault.load_index() == original + + +def test_cli_export_import_unencrypted(monkeypatch, tmp_path): + pm, vault = _setup_pm(tmp_path) + data = { + "schema_version": 4, + "entries": { + "0": { + "label": "example", + "type": "password", + "notes": "", + "custom_fields": [], + "origin": "", + "tags": [], + } + }, + } + vault.save_index(data) + + monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm) + monkeypatch.setattr(main, "configure_logging", lambda: None) + monkeypatch.setattr(main, "initialize_app", lambda: None) + monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) + + export_path = tmp_path / "out.json" + rc = main.main(["export", "--file", str(export_path), "--unencrypted"]) + assert rc == 0 + assert export_path.exists() + + vault.save_index({"schema_version": 4, "entries": {}}) + rc = main.main(["import", "--file", str(export_path)]) + assert rc == 0 + assert vault.load_index() == data diff --git a/src/tests/test_portable_backup.py b/src/tests/test_portable_backup.py index edd4cfd..e76f7ef 100644 --- a/src/tests/test_portable_backup.py +++ b/src/tests/test_portable_backup.py @@ -15,6 +15,7 @@ from seedpass.core.vault import Vault from seedpass.core.backup import BackupManager from seedpass.core.config_manager import ConfigManager from seedpass.core.portable_backup import export_backup, import_backup +from seedpass.core.portable_backup import PortableMode from utils.key_derivation import derive_index_key, derive_key_from_password from utils.fingerprint import generate_fingerprint @@ -54,6 +55,22 @@ def test_round_trip(monkeypatch): assert vault.load_index()["pw"] == data["pw"] +def test_round_trip_unencrypted(monkeypatch): + with TemporaryDirectory() as td: + tmp = Path(td) + vault, backup, _ = setup_vault(tmp) + data = {"pw": 1} + vault.save_index(data) + + path = export_backup(vault, backup, parent_seed=SEED, encrypt=False) + wrapper = json.loads(path.read_text()) + assert wrapper["encryption_mode"] == PortableMode.NONE.value + + vault.save_index({"pw": 0}) + import_backup(vault, backup, path, parent_seed=SEED) + assert vault.load_index()["pw"] == data["pw"] + + from cryptography.fernet import InvalidToken