From 2b22fd7d5e031bf4c91ff48f6755f5c2b2ff497f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 4 Aug 2025 12:38:04 -0400 Subject: [PATCH] Support multiple legacy PBKDF2 iterations --- src/seedpass/core/encryption.py | 54 +++++++++------ src/tests/test_legacy_migration_iterations.py | 66 +++++++++++++++++++ 2 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 src/tests/test_legacy_migration_iterations.py diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 2ed2fc0..f669ff7 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -161,25 +161,41 @@ class EncryptionManager: raise InvalidToken( "User declined legacy decryption or provided invalid choice." ) from e - try: - password = prompt_existing_password( - "Enter your master password for legacy decryption: " - ) - legacy_key = _derive_legacy_key_from_password( - password, iterations=50_000 - ) - legacy_mgr = EncryptionManager(legacy_key, self.fingerprint_dir) - legacy_mgr._legacy_migrate_flag = False - result = legacy_mgr.decrypt_data(encrypted_data) - logger.warning( - "Data decrypted using legacy password-only key derivation." - ) - return result - except Exception as e2: # pragma: no cover - exceptional path - logger.error(f"Failed legacy decryption attempt: {e2}", exc_info=True) - raise InvalidToken( - "Could not decrypt data with any available method." - ) from e + password = prompt_existing_password( + "Enter your master password for legacy decryption: " + ) + last_exc: Optional[Exception] = None + for iter_count in [50_000, 100_000]: + try: + legacy_key = _derive_legacy_key_from_password( + password, iterations=iter_count + ) + legacy_mgr = EncryptionManager(legacy_key, self.fingerprint_dir) + legacy_mgr._legacy_migrate_flag = False + result = legacy_mgr.decrypt_data(encrypted_data) + try: # record iteration count for future runs + from .vault import Vault + from .config_manager import ConfigManager + + cfg_mgr = ConfigManager( + Vault(self, self.fingerprint_dir), self.fingerprint_dir + ) + cfg_mgr.set_kdf_iterations(iter_count) + except Exception: # pragma: no cover - best effort + logger.error( + "Failed to record PBKDF2 iteration count in config", + exc_info=True, + ) + logger.warning( + "Data decrypted using legacy password-only key derivation." + ) + return result + except Exception as e2: # pragma: no cover - try next iteration + last_exc = e2 + logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True) + raise InvalidToken( + "Could not decrypt data with any available method." + ) from e # --- All functions below this point now use the smart `decrypt_data` method --- diff --git a/src/tests/test_legacy_migration_iterations.py b/src/tests/test_legacy_migration_iterations.py new file mode 100644 index 0000000..478270d --- /dev/null +++ b/src/tests/test_legacy_migration_iterations.py @@ -0,0 +1,66 @@ +import base64 +import json +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +import pytest + +import seedpass.core.encryption as enc_module +from helpers import TEST_PASSWORD +from seedpass.core.encryption import ( + EncryptionManager, + _derive_legacy_key_from_password, +) +from seedpass.core.config_manager import ConfigManager +from seedpass.core.vault import Vault + + +def _setup_legacy_file(tmp_path: Path, iterations: int) -> None: + legacy_key = _derive_legacy_key_from_password(TEST_PASSWORD, iterations=iterations) + mgr = EncryptionManager(legacy_key, tmp_path) + data = {"entries": {"0": {"kind": "test"}}} + json_bytes = json.dumps(data, separators=(",", ":")).encode("utf-8") + legacy_encrypted = mgr.fernet.encrypt(json_bytes) + (tmp_path / "seedpass_entries_db.json.enc").write_bytes(legacy_encrypted) + + +@pytest.mark.parametrize("iterations", [50_000, 100_000]) +def test_migrate_iterations(tmp_path, monkeypatch, iterations): + _setup_legacy_file(tmp_path, iterations) + + new_key = base64.urlsafe_b64encode(b"B" * 32) + mgr = EncryptionManager(new_key, tmp_path) + + prompts: list[int] = [] + + def fake_prompt(_msg: str) -> str: + prompts.append(1) + return TEST_PASSWORD + + monkeypatch.setattr(enc_module, "prompt_existing_password", fake_prompt) + monkeypatch.setattr("builtins.input", lambda *_a, **_k: "2") + + calls: list[int] = [] + orig_derive = enc_module._derive_legacy_key_from_password + + def tracking_derive(password: str, iterations: int = 100_000) -> bytes: + calls.append(iterations) + return orig_derive(password, iterations=iterations) + + monkeypatch.setattr(enc_module, "_derive_legacy_key_from_password", tracking_derive) + + mgr.load_json_data() + # Loading again should not prompt for password or attempt legacy counts + mgr.load_json_data() + + assert prompts == [1] + expected = [50_000] if iterations == 50_000 else [50_000, 100_000] + assert calls == expected + + cfg = ConfigManager(Vault(mgr, tmp_path), tmp_path) + assert cfg.get_kdf_iterations() == iterations + + content = (tmp_path / "seedpass_entries_db.json.enc").read_bytes() + assert content.startswith(b"V2:")