From 1301b79279687727ab0d1b5dcdd6e6c9d3ae3495 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sun, 3 Aug 2025 16:47:13 -0400 Subject: [PATCH] test: cover legacy index decryption fallback --- src/seedpass/core/encryption.py | 58 +++++++++++++++++-- .../test_nostr_legacy_decrypt_fallback.py | 35 +++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/tests/test_nostr_legacy_decrypt_fallback.py diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 8f612a7..a7034fc 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -2,6 +2,7 @@ import logging import traceback +import unicodedata try: import orjson as json_lib # type: ignore @@ -25,11 +26,19 @@ from cryptography.fernet import Fernet, InvalidToken from termcolor import colored from utils.file_lock import exclusive_lock from mnemonic import Mnemonic +from utils.password_prompt import prompt_existing_password # Instantiate the logger logger = logging.getLogger(__name__) +def _derive_legacy_key_from_password(password: str, iterations: int = 100_000) -> bytes: + """Derive legacy Fernet key using password only (no fingerprint).""" + normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8") + key = hashlib.pbkdf2_hmac("sha256", normalized, b"", iterations, dklen=32) + return base64.urlsafe_b64encode(key) + + class EncryptionManager: """ Manages encryption and decryption, handling migration from legacy Fernet @@ -268,12 +277,12 @@ class EncryptionManager: """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - try: - decrypted_data = self.decrypt_data(encrypted_data) + + def _process(decrypted: bytes) -> dict: if USE_ORJSON: - data = json_lib.loads(decrypted_data) + data = json_lib.loads(decrypted) else: - data = json_lib.loads(decrypted_data.decode("utf-8")) + data = json_lib.loads(decrypted.decode("utf-8")) existing_file = self.resolve_relative_path(relative_path) if merge and existing_file.exists(): current = self.load_json_data(relative_path) @@ -289,11 +298,52 @@ class EncryptionManager: current.get("schema_version", 0), data.get("schema_version", 0) ) data = current + return data + + try: + decrypted_data = self.decrypt_data(encrypted_data) + data = _process(decrypted_data) self.save_json_data(data, relative_path) # This always saves in V2 format self.update_checksum(relative_path) logger.info("Index file from Nostr was processed and saved successfully.") print(colored("Index file updated from Nostr successfully.", "green")) return True + except InvalidToken as e: + try: + password = prompt_existing_password( + "Enter your master password for legacy decryption: " + ) + legacy_key = _derive_legacy_key_from_password(password) + legacy_mgr = EncryptionManager(legacy_key, self.fingerprint_dir) + decrypted_data = legacy_mgr.decrypt_data(encrypted_data) + data = _process(decrypted_data) + self.save_json_data(data, relative_path) + self.update_checksum(relative_path) + logger.warning( + "Index decrypted using legacy password-only key derivation." + ) + print( + colored( + "Warning: index decrypted with legacy key; it will be re-encrypted.", + "yellow", + ) + ) + return True + except Exception as e2: + if strict: + logger.error( + f"Failed legacy decryption attempt: {e2}", + exc_info=True, + ) + print( + colored( + f"Error: Failed to decrypt and save data from Nostr: {e2}", + "red", + ) + ) + raise + logger.warning(f"Failed to decrypt index from Nostr: {e2}") + return False except Exception as e: # pragma: no cover - error handling if strict: logger.error( diff --git a/src/tests/test_nostr_legacy_decrypt_fallback.py b/src/tests/test_nostr_legacy_decrypt_fallback.py new file mode 100644 index 0000000..ef9d4b6 --- /dev/null +++ b/src/tests/test_nostr_legacy_decrypt_fallback.py @@ -0,0 +1,35 @@ +import json +import base64 +import hashlib +import unicodedata +import logging +from cryptography.fernet import Fernet + +from helpers import create_vault, TEST_PASSWORD +import seedpass.core.encryption as enc_module + + +def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes: + normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8") + key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32) + return base64.urlsafe_b64encode(key) + + +def test_legacy_password_only_fallback(monkeypatch, tmp_path, caplog): + # Speed up legacy key derivation + monkeypatch.setattr( + enc_module, "_derive_legacy_key_from_password", _fast_legacy_key + ) + monkeypatch.setattr( + enc_module, "prompt_existing_password", lambda *_a, **_k: TEST_PASSWORD + ) + + vault, enc_mgr = create_vault(tmp_path) + data = {"schema_version": 4, "entries": {}} + legacy_key = _fast_legacy_key(TEST_PASSWORD) + encrypted = Fernet(legacy_key).encrypt(json.dumps(data).encode()) + + caplog.set_level(logging.WARNING) + assert enc_mgr.decrypt_and_save_index_from_nostr(encrypted) + assert vault.load_index() == data + assert any("legacy password-only" in rec.message for rec in caplog.records)