diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 76295ff..c71be79 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -40,6 +40,7 @@ from utils.password_prompt import ( prompt_existing_password, confirm_action, ) +from utils.memory_protection import InMemorySecret from constants import MIN_HEALTHY_RELAYS from constants import ( @@ -93,7 +94,7 @@ class PasswordManager: self.backup_manager: Optional[BackupManager] = None self.vault: Optional[Vault] = None self.fingerprint_manager: Optional[FingerprintManager] = None - self.parent_seed: Optional[str] = None + self._parent_seed_secret: Optional[InMemorySecret] = None self.bip85: Optional[BIP85] = None self.nostr_client: Optional[NostrClient] = None self.config_manager: Optional[ConfigManager] = None @@ -114,6 +115,22 @@ class PasswordManager: # Set the current fingerprint directory self.fingerprint_dir = self.fingerprint_manager.get_current_fingerprint_dir() + @property + def parent_seed(self) -> Optional[str]: + """Return the decrypted parent seed if set.""" + if self._parent_seed_secret is None: + return None + return self._parent_seed_secret.get_str() + + @parent_seed.setter + def parent_seed(self, value: Optional[str]) -> None: + if value is None: + if self._parent_seed_secret: + self._parent_seed_secret.wipe() + self._parent_seed_secret = None + else: + self._parent_seed_secret = InMemorySecret(value.encode("utf-8")) + def update_activity(self) -> None: """Record the current time as the last user activity.""" self.last_activity = time.time() diff --git a/src/utils/__init__.py b/src/utils/__init__.py index 5a96481..95a731d 100644 --- a/src/utils/__init__.py +++ b/src/utils/__init__.py @@ -24,6 +24,7 @@ try: ) from .password_prompt import prompt_for_password from .input_utils import timed_input + from .memory_protection import InMemorySecret if logger.isEnabledFor(logging.DEBUG): logger.info("Modules imported successfully.") @@ -47,4 +48,5 @@ __all__ = [ "shared_lock", "prompt_for_password", "timed_input", + "InMemorySecret", ] diff --git a/src/utils/memory_protection.py b/src/utils/memory_protection.py new file mode 100644 index 0000000..cc604c1 --- /dev/null +++ b/src/utils/memory_protection.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class InMemorySecret: + """Store sensitive data encrypted in RAM using AES-GCM.""" + + def __init__(self, data: bytes) -> None: + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must be bytes") + self._key = AESGCM.generate_key(bit_length=128) + self._nonce = os.urandom(12) + self._cipher = AESGCM(self._key) + self._encrypted = self._cipher.encrypt(self._nonce, bytes(data), None) + + def get_bytes(self) -> bytes: + """Decrypt and return the plaintext bytes.""" + return self._cipher.decrypt(self._nonce, self._encrypted, None) + + def wipe(self) -> None: + """Zero out internal data.""" + self._key = None + self._nonce = None + self._cipher = None + self._encrypted = None + + def get_str(self) -> str: + """Return the decrypted plaintext as a UTF-8 string.""" + return self.get_bytes().decode("utf-8")