diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index a7034fc..4599699 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -94,35 +94,59 @@ class EncryptionManager: (3) The core migration logic. Tries the new format first, then falls back to the old one. This is the ONLY place decryption logic should live. """ - # Try the new V2 format first - if encrypted_data.startswith(b"V2:"): - try: - nonce = encrypted_data[3:15] - ciphertext = encrypted_data[15:] - if len(ciphertext) < 16: - logger.error("AES-GCM payload too short") - raise InvalidToken("AES-GCM payload too short") - return self.cipher.decrypt(nonce, ciphertext, None) - except InvalidTag as e: - logger.error("AES-GCM decryption failed: Invalid authentication tag.") + try: + # Try the new V2 format first + if encrypted_data.startswith(b"V2:"): try: - result = self.fernet.decrypt(encrypted_data[3:]) - logger.warning( - "Legacy-format file had incorrect 'V2:' header; decrypted with Fernet" + nonce = encrypted_data[3:15] + ciphertext = encrypted_data[15:] + if len(ciphertext) < 16: + logger.error("AES-GCM payload too short") + raise InvalidToken("AES-GCM payload too short") + return self.cipher.decrypt(nonce, ciphertext, None) + except InvalidTag as e: + logger.error( + "AES-GCM decryption failed: Invalid authentication tag." ) - return result - except InvalidToken: - raise InvalidToken("AES-GCM decryption failed.") from e + try: + result = self.fernet.decrypt(encrypted_data[3:]) + logger.warning( + "Legacy-format file had incorrect 'V2:' header; decrypted with Fernet" + ) + return result + except InvalidToken: + raise InvalidToken("AES-GCM decryption failed.") from e - # If it's not V2, it must be the legacy Fernet format - else: - logger.warning("Data is in legacy Fernet format. Attempting migration.") + # If it's not V2, it must be the legacy Fernet format + else: + logger.warning("Data is in legacy Fernet format. Attempting migration.") + try: + return self.fernet.decrypt(encrypted_data) + except InvalidToken as e: + logger.error( + "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." + ) + raise InvalidToken( + "Could not decrypt data with any available method." + ) from e + + except (InvalidToken, InvalidTag) as e: + if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short": + raise + logger.error(f"FATAL: Could not decrypt data: {e}", exc_info=True) try: - return self.fernet.decrypt(encrypted_data) - except InvalidToken as e: - logger.error( - "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." + 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) + 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 diff --git a/src/tests/test_decrypt_data_legacy_fallback.py b/src/tests/test_decrypt_data_legacy_fallback.py new file mode 100644 index 0000000..ab90cde --- /dev/null +++ b/src/tests/test_decrypt_data_legacy_fallback.py @@ -0,0 +1,32 @@ +import base64 +import hashlib +import unicodedata + +from helpers import TEST_PASSWORD +import seedpass.core.encryption as enc_module +from seedpass.core.encryption import EncryptionManager +from utils.key_derivation import derive_key_from_password + + +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_decrypt_data_password_fallback(tmp_path, monkeypatch): + monkeypatch.setattr( + enc_module, "_derive_legacy_key_from_password", _fast_legacy_key + ) + monkeypatch.setattr( + enc_module, "prompt_existing_password", lambda *_a, **_k: TEST_PASSWORD + ) + + legacy_key = _fast_legacy_key(TEST_PASSWORD) + legacy_mgr = EncryptionManager(legacy_key, tmp_path) + payload = legacy_mgr.encrypt_data(b"secret") + + new_key = derive_key_from_password(TEST_PASSWORD, "fp") + new_mgr = EncryptionManager(new_key, tmp_path) + + assert new_mgr.decrypt_data(payload) == b"secret"