Merge pull request #852 from PR0M3TH3AN/codex/handle-encryption-exceptions-with-user-friendly-message

Handle decryption failures with friendly message
This commit is contained in:
thePR0M3TH3AN
2025-08-23 12:30:05 -04:00
committed by GitHub
4 changed files with 84 additions and 31 deletions

View File

@@ -29,6 +29,7 @@ from utils.file_lock import exclusive_lock
from mnemonic import Mnemonic from mnemonic import Mnemonic
from utils.password_prompt import prompt_existing_password from utils.password_prompt import prompt_existing_password
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
from .errors import DecryptionError
# Instantiate the logger # Instantiate the logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -137,12 +138,15 @@ class EncryptionManager:
ciphertext = encrypted_data[15:] ciphertext = encrypted_data[15:]
if len(ciphertext) < 16: if len(ciphertext) < 16:
logger.error("AES-GCM payload too short") logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short") raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None) return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e: except InvalidTag as e:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file" logger.error(f"Failed to decrypt{ctx}: invalid key or corrupt file")
logger.error(msg) raise DecryptionError(
raise InvalidToken(msg) from e f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# Next try the older V2 format # Next try the older V2 format
if encrypted_data.startswith(b"V2:"): if encrypted_data.startswith(b"V2:"):
@@ -151,7 +155,9 @@ class EncryptionManager:
ciphertext = encrypted_data[15:] ciphertext = encrypted_data[15:]
if len(ciphertext) < 16: if len(ciphertext) < 16:
logger.error("AES-GCM payload too short") logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short") raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None) return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e: except InvalidTag as e:
logger.debug( logger.debug(
@@ -164,9 +170,12 @@ class EncryptionManager:
) )
return result return result
except InvalidToken: except InvalidToken:
msg = f"Failed to decrypt{ctx}: invalid key or corrupt file" logger.error(
logger.error(msg) f"Failed to decrypt{ctx}: invalid key or corrupt file"
raise InvalidToken(msg) from e )
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# If it's neither V3 nor V2, assume legacy Fernet format # If it's neither V3 nor V2, assume legacy Fernet format
logger.warning("Data is in legacy Fernet format. Attempting migration.") logger.warning("Data is in legacy Fernet format. Attempting migration.")
@@ -176,18 +185,23 @@ class EncryptionManager:
logger.error( logger.error(
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
) )
raise e raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
except (InvalidToken, InvalidTag) as e: except DecryptionError as e:
if encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:"): if (
# Already determined not to be legacy; re-raise encrypted_data.startswith(b"V3|")
raise or encrypted_data.startswith(b"V2:")
if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short": or not self._legacy_migrate_flag
raise ):
if not self._legacy_migrate_flag:
raise raise
logger.debug(f"Could not decrypt data{ctx}: {e}") logger.debug(f"Could not decrypt data{ctx}: {e}")
raise LegacyFormatRequiresMigrationError(context) raise LegacyFormatRequiresMigrationError(context) from e
except (InvalidToken, InvalidTag) as e: # pragma: no cover - safety net
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
def decrypt_legacy( def decrypt_legacy(
self, encrypted_data: bytes, password: str, context: Optional[str] = None self, encrypted_data: bytes, password: str, context: Optional[str] = None
@@ -224,8 +238,8 @@ class EncryptionManager:
except Exception as e2: # pragma: no cover - try next iteration except Exception as e2: # pragma: no cover - try next iteration
last_exc = e2 last_exc = e2
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True) logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
raise InvalidToken( raise DecryptionError(
f"Could not decrypt{ctx} with any available method." f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from last_exc ) from last_exc
# --- All functions below this point now use the smart `decrypt_data` method --- # --- All functions below this point now use the smart `decrypt_data` method ---
@@ -409,10 +423,16 @@ class EncryptionManager:
if return_kdf: if return_kdf:
return data, kdf return data, kdf
return data return data
except (InvalidToken, InvalidTag) as e: except DecryptionError as e:
msg = f"Failed to decrypt or parse data from {file_path}: {e}" msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg) logger.error(msg)
raise InvalidToken(msg) from e raise
except (InvalidToken, InvalidTag) as e: # pragma: no cover - legacy safety
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise DecryptionError(
f"Failed to decrypt {file_path}: invalid key or corrupt file"
) from e
except JSONDecodeError as e: except JSONDecodeError as e:
msg = f"Failed to parse JSON data from {file_path}: {e}" msg = f"Failed to parse JSON data from {file_path}: {e}"
logger.error(msg) logger.error(msg)
@@ -484,7 +504,7 @@ class EncryptionManager:
logger.info("Index file from Nostr was processed and saved successfully.") logger.info("Index file from Nostr was processed and saved successfully.")
self.last_migration_performed = is_legacy self.last_migration_performed = is_legacy
return True return True
except (InvalidToken, LegacyFormatRequiresMigrationError): except (DecryptionError, LegacyFormatRequiresMigrationError):
try: try:
password = prompt_existing_password( password = prompt_existing_password(
"Enter your master password for legacy decryption: " "Enter your master password for legacy decryption: "

View File

@@ -9,6 +9,7 @@ exception, displaying a friendly message and exiting with code ``1``.
""" """
from click import ClickException from click import ClickException
from cryptography.fernet import InvalidToken
class SeedPassError(ClickException): class SeedPassError(ClickException):
@@ -18,4 +19,12 @@ class SeedPassError(ClickException):
super().__init__(message) super().__init__(message)
__all__ = ["SeedPassError"] class DecryptionError(InvalidToken, SeedPassError):
"""Raised when encrypted data cannot be decrypted.
Subclasses :class:`cryptography.fernet.InvalidToken` so callers expecting
the cryptography exception continue to work.
"""
__all__ = ["SeedPassError", "DecryptionError"]

View File

@@ -37,8 +37,7 @@ from .password_generation import PasswordGenerator
from .backup import BackupManager from .backup import BackupManager
from .vault import Vault from .vault import Vault
from .portable_backup import export_backup, import_backup, PortableMode from .portable_backup import export_backup, import_backup, PortableMode
from cryptography.fernet import InvalidToken from .errors import SeedPassError, DecryptionError
from .errors import SeedPassError
from .totp import TotpManager from .totp import TotpManager
from .entry_types import EntryType from .entry_types import EntryType
from .pubsub import bus from .pubsub import bus
@@ -752,13 +751,11 @@ class PasswordManager:
): ):
self.config_manager.set_kdf_iterations(iter_try) self.config_manager.set_kdf_iterations(iter_try)
break break
except InvalidToken: except DecryptionError:
seed_mgr = None seed_mgr = None
if seed_mgr is None: if seed_mgr is None:
msg = ( msg = "Incorrect password or corrupt file"
"Invalid password for selected seed profile. Please try again."
)
print(colored(msg, "red")) print(colored(msg, "red"))
attempts += 1 attempts += 1
password = None password = None
@@ -830,6 +827,10 @@ class PasswordManager:
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
self.derive_key_hierarchy(seed_bytes) self.derive_key_hierarchy(seed_bytes)
self.bip85 = BIP85(seed_bytes) self.bip85 = BIP85(seed_bytes)
except DecryptionError as e:
logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored("Incorrect password or corrupt file", "red"))
raise SeedPassError("Incorrect password or corrupt file") from e
except Exception as e: except Exception as e:
logger.error(f"Failed to load parent seed: {e}", exc_info=True) logger.error(f"Failed to load parent seed: {e}", exc_info=True)
print(colored(f"Error: Failed to load parent seed: {e}", "red")) print(colored(f"Error: Failed to load parent seed: {e}", "red"))
@@ -4246,7 +4247,7 @@ class PasswordManager:
src, src,
parent_seed=self.parent_seed, parent_seed=self.parent_seed,
) )
except InvalidToken: except DecryptionError:
logging.error("Invalid backup token during import", exc_info=True) logging.error("Invalid backup token during import", exc_info=True)
print( print(
colored( colored(
@@ -4488,7 +4489,7 @@ class PasswordManager:
else: else:
logging.warning("Password verification failed.") logging.warning("Password verification failed.")
return is_correct return is_correct
except InvalidToken as e: except DecryptionError as e:
logging.error(f"Failed to decrypt config: {e}") logging.error(f"Failed to decrypt config: {e}")
print( print(
colored( colored(

View File

@@ -0,0 +1,23 @@
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from seedpass.core.manager import PasswordManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.errors import SeedPassError
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
def test_invalid_password_shows_friendly_message_once(capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
pm = PasswordManager.__new__(PasswordManager)
pm.config_manager = ConfigManager(vault, tmp_path)
pm.fingerprint_dir = tmp_path
pm.parent_seed = ""
with pytest.raises(SeedPassError):
pm.load_parent_seed(tmp_path, password="wrongpass")
captured = capsys.readouterr().out
assert captured.count("Incorrect password or corrupt file") == 1