From bebbca8169515b26f61da83cb041a33c0f68efa3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:05:58 -0400 Subject: [PATCH 1/2] Add test for friendly decryption message? --- src/seedpass/core/encryption.py | 54 +++++++++++++--------- src/seedpass/core/errors.py | 6 ++- src/seedpass/core/manager.py | 17 +++---- src/tests/test_invalid_password_message.py | 23 +++++++++ 4 files changed, 68 insertions(+), 32 deletions(-) create mode 100644 src/tests/test_invalid_password_message.py diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 47c0b99..55f1d8b 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -29,6 +29,7 @@ from utils.file_lock import exclusive_lock from mnemonic import Mnemonic from utils.password_prompt import prompt_existing_password from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION +from .errors import DecryptionError # Instantiate the logger logger = logging.getLogger(__name__) @@ -137,12 +138,13 @@ class EncryptionManager: ciphertext = encrypted_data[15:] if len(ciphertext) < 16: logger.error("AES-GCM payload too short") - raise InvalidToken("AES-GCM payload too short") + raise DecryptionError("Incorrect password or corrupt file") return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: - msg = f"Failed to decrypt{ctx}: invalid key or corrupt file" - logger.error(msg) - raise InvalidToken(msg) from e + logger.error( + f"Failed to decrypt{ctx}: incorrect password or corrupt file" + ) + raise DecryptionError("Incorrect password or corrupt file") from e # Next try the older V2 format if encrypted_data.startswith(b"V2:"): @@ -151,7 +153,7 @@ class EncryptionManager: ciphertext = encrypted_data[15:] if len(ciphertext) < 16: logger.error("AES-GCM payload too short") - raise InvalidToken("AES-GCM payload too short") + raise DecryptionError("Incorrect password or corrupt file") return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: logger.debug( @@ -164,9 +166,12 @@ class EncryptionManager: ) return result except InvalidToken: - msg = f"Failed to decrypt{ctx}: invalid key or corrupt file" - logger.error(msg) - raise InvalidToken(msg) from e + logger.error( + f"Failed to decrypt{ctx}: incorrect password or corrupt file" + ) + raise DecryptionError( + "Incorrect password or corrupt file" + ) from e # If it's neither V3 nor V2, assume legacy Fernet format logger.warning("Data is in legacy Fernet format. Attempting migration.") @@ -176,18 +181,19 @@ class EncryptionManager: logger.error( "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." ) - raise e + raise DecryptionError("Incorrect password or corrupt file") from e - except (InvalidToken, InvalidTag) as e: - if encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:"): - # Already determined not to be legacy; re-raise - raise - if isinstance(e, InvalidToken) and str(e) == "AES-GCM payload too short": - raise - if not self._legacy_migrate_flag: + except DecryptionError as e: + if ( + encrypted_data.startswith(b"V3|") + or encrypted_data.startswith(b"V2:") + or not self._legacy_migrate_flag + ): raise 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("Incorrect password or corrupt file") from e def decrypt_legacy( self, encrypted_data: bytes, password: str, context: Optional[str] = None @@ -224,9 +230,7 @@ class EncryptionManager: 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( - f"Could not decrypt{ctx} with any available method." - ) from last_exc + raise DecryptionError(f"Incorrect password or corrupt file") from last_exc # --- All functions below this point now use the smart `decrypt_data` method --- @@ -409,10 +413,14 @@ class EncryptionManager: if return_kdf: return data, kdf return data - except (InvalidToken, InvalidTag) as e: + except DecryptionError as e: msg = f"Failed to decrypt or parse data from {file_path}: {e}" 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("Incorrect password or corrupt file") from e except JSONDecodeError as e: msg = f"Failed to parse JSON data from {file_path}: {e}" logger.error(msg) @@ -484,7 +492,7 @@ class EncryptionManager: logger.info("Index file from Nostr was processed and saved successfully.") self.last_migration_performed = is_legacy return True - except (InvalidToken, LegacyFormatRequiresMigrationError): + except (DecryptionError, LegacyFormatRequiresMigrationError): try: password = prompt_existing_password( "Enter your master password for legacy decryption: " diff --git a/src/seedpass/core/errors.py b/src/seedpass/core/errors.py index d84bb54..6f3e033 100644 --- a/src/seedpass/core/errors.py +++ b/src/seedpass/core/errors.py @@ -18,4 +18,8 @@ class SeedPassError(ClickException): super().__init__(message) -__all__ = ["SeedPassError"] +class DecryptionError(SeedPassError): + """Raised when encrypted data cannot be decrypted.""" + + +__all__ = ["SeedPassError", "DecryptionError"] diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 690aef0..9c25101 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -37,8 +37,7 @@ from .password_generation import PasswordGenerator from .backup import BackupManager from .vault import Vault from .portable_backup import export_backup, import_backup, PortableMode -from cryptography.fernet import InvalidToken -from .errors import SeedPassError +from .errors import SeedPassError, DecryptionError from .totp import TotpManager from .entry_types import EntryType from .pubsub import bus @@ -752,13 +751,11 @@ class PasswordManager: ): self.config_manager.set_kdf_iterations(iter_try) break - except InvalidToken: + except DecryptionError: seed_mgr = None if seed_mgr is None: - msg = ( - "Invalid password for selected seed profile. Please try again." - ) + msg = "Incorrect password or corrupt file" print(colored(msg, "red")) attempts += 1 password = None @@ -830,6 +827,10 @@ class PasswordManager: seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() self.derive_key_hierarchy(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: logger.error(f"Failed to load parent seed: {e}", exc_info=True) print(colored(f"Error: Failed to load parent seed: {e}", "red")) @@ -4246,7 +4247,7 @@ class PasswordManager: src, parent_seed=self.parent_seed, ) - except InvalidToken: + except DecryptionError: logging.error("Invalid backup token during import", exc_info=True) print( colored( @@ -4488,7 +4489,7 @@ class PasswordManager: else: logging.warning("Password verification failed.") return is_correct - except InvalidToken as e: + except DecryptionError as e: logging.error(f"Failed to decrypt config: {e}") print( colored( diff --git a/src/tests/test_invalid_password_message.py b/src/tests/test_invalid_password_message.py new file mode 100644 index 0000000..5fac05f --- /dev/null +++ b/src/tests/test_invalid_password_message.py @@ -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 From d030cf9692ed81a9ae1b85deb19b4802ff05f043 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 23 Aug 2025 12:21:56 -0400 Subject: [PATCH 2/2] Raise InvalidToken-compatible errors --- src/seedpass/core/encryption.py | 36 ++++++++++++++++++++++----------- src/seedpass/core/errors.py | 9 +++++++-- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index 55f1d8b..b0b5c7c 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -138,13 +138,15 @@ class EncryptionManager: ciphertext = encrypted_data[15:] if len(ciphertext) < 16: logger.error("AES-GCM payload too short") - raise DecryptionError("Incorrect password or corrupt file") + raise DecryptionError( + f"Failed to decrypt{ctx}: AES-GCM payload too short" + ) return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: - logger.error( - f"Failed to decrypt{ctx}: incorrect password or corrupt file" - ) - raise DecryptionError("Incorrect password or corrupt file") from e + logger.error(f"Failed to decrypt{ctx}: invalid key or corrupt file") + raise DecryptionError( + f"Failed to decrypt{ctx}: invalid key or corrupt file" + ) from e # Next try the older V2 format if encrypted_data.startswith(b"V2:"): @@ -153,7 +155,9 @@ class EncryptionManager: ciphertext = encrypted_data[15:] if len(ciphertext) < 16: logger.error("AES-GCM payload too short") - raise DecryptionError("Incorrect password or corrupt file") + raise DecryptionError( + f"Failed to decrypt{ctx}: AES-GCM payload too short" + ) return self.cipher.decrypt(nonce, ciphertext, None) except InvalidTag as e: logger.debug( @@ -167,10 +171,10 @@ class EncryptionManager: return result except InvalidToken: logger.error( - f"Failed to decrypt{ctx}: incorrect password or corrupt file" + f"Failed to decrypt{ctx}: invalid key or corrupt file" ) raise DecryptionError( - "Incorrect password or corrupt file" + f"Failed to decrypt{ctx}: invalid key or corrupt file" ) from e # If it's neither V3 nor V2, assume legacy Fernet format @@ -181,7 +185,9 @@ class EncryptionManager: logger.error( "Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect." ) - raise DecryptionError("Incorrect password or corrupt file") from e + raise DecryptionError( + f"Failed to decrypt{ctx}: invalid key or corrupt file" + ) from e except DecryptionError as e: if ( @@ -193,7 +199,9 @@ class EncryptionManager: logger.debug(f"Could not decrypt data{ctx}: {e}") raise LegacyFormatRequiresMigrationError(context) from e except (InvalidToken, InvalidTag) as e: # pragma: no cover - safety net - raise DecryptionError("Incorrect password or corrupt file") from e + raise DecryptionError( + f"Failed to decrypt{ctx}: invalid key or corrupt file" + ) from e def decrypt_legacy( self, encrypted_data: bytes, password: str, context: Optional[str] = None @@ -230,7 +238,9 @@ class EncryptionManager: 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 DecryptionError(f"Incorrect password or corrupt file") from last_exc + raise DecryptionError( + f"Failed to decrypt{ctx}: invalid key or corrupt file" + ) from last_exc # --- All functions below this point now use the smart `decrypt_data` method --- @@ -420,7 +430,9 @@ class EncryptionManager: 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("Incorrect password or corrupt file") from e + raise DecryptionError( + f"Failed to decrypt {file_path}: invalid key or corrupt file" + ) from e except JSONDecodeError as e: msg = f"Failed to parse JSON data from {file_path}: {e}" logger.error(msg) diff --git a/src/seedpass/core/errors.py b/src/seedpass/core/errors.py index 6f3e033..85bc99c 100644 --- a/src/seedpass/core/errors.py +++ b/src/seedpass/core/errors.py @@ -9,6 +9,7 @@ exception, displaying a friendly message and exiting with code ``1``. """ from click import ClickException +from cryptography.fernet import InvalidToken class SeedPassError(ClickException): @@ -18,8 +19,12 @@ class SeedPassError(ClickException): super().__init__(message) -class DecryptionError(SeedPassError): - """Raised when encrypted data cannot be decrypted.""" +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"]