mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-10-27 07:52:03 +00:00
Fix weak key derivation by adding salt to parent seed encryption
Introduced a random salt to key derivation for parent seed encryption to prevent rainbow table attacks. The salt is now stored in the `parent_seed.enc` file header (V3 format). * Modified `src/utils/key_derivation.py` to accept optional `salt` argument. * Modified `src/seedpass/core/encryption.py` to read/write salt in `parent_seed.enc` using V3 format (V3:salt:nonce:ciphertext). * Modified `src/seedpass/core/manager.py` to generate salt for new seeds and read salt for existing ones. * Added auto-upgrade logic for legacy (no salt) seeds upon access. * Updated tests to verify salt usage and backward compatibility. Co-authored-by: PR0M3TH3AN <[email protected]>
This commit is contained in:
1 parent
335de7abe3
commit
75fe5bc382
4 files changed
+118
-27
No files matched your search
@@ -29,6 +29,28 @@ from utils.file_lock import exclusive_lock
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_salt_from_parent_seed_file(file_path: Path) -> bytes:
|
||||
"""
|
||||
Reads the salt from the parent seed file if it exists and uses the V3 format.
|
||||
|
||||
Parameters:
|
||||
file_path (Path): The path to the parent seed file.
|
||||
|
||||
Returns:
|
||||
bytes: The 16-byte salt if found, otherwise an empty bytes object.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return b""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header = f.read(3)
|
||||
if header == b"V3:":
|
||||
return f.read(16)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read salt from {file_path}: {e}")
|
||||
return b""
|
||||
|
||||
|
||||
class EncryptionManager:
|
||||
"""
|
||||
Manages encryption and decryption, handling migration from legacy Fernet
|
||||
@@ -119,10 +141,23 @@ class EncryptionManager:
|
||||
|
||||
# --- All functions below this point now use the smart `decrypt_data` method ---
|
||||
|
||||
def encrypt_parent_seed(self, parent_seed: str) -> None:
|
||||
def encrypt_parent_seed(self, parent_seed: str, salt: bytes = b"") -> None:
|
||||
"""Encrypts and saves the parent seed to 'parent_seed.enc'."""
|
||||
data = parent_seed.encode("utf-8")
|
||||
encrypted_data = self.encrypt_data(data) # This now creates V2 format
|
||||
|
||||
if salt:
|
||||
# V3 format: V3:<salt(16)><nonce(12)><ciphertext>
|
||||
try:
|
||||
nonce = os.urandom(12)
|
||||
ciphertext = self.cipher.encrypt(nonce, data, None)
|
||||
encrypted_data = b"V3:" + salt + nonce + ciphertext
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt parent seed with V3 format: {e}", exc_info=True)
|
||||
raise
|
||||
else:
|
||||
# Fallback to V2 (no salt stored) or use encrypt_data
|
||||
encrypted_data = self.encrypt_data(data) # This creates V2 format
|
||||
|
||||
with exclusive_lock(self.parent_seed_file) as fh:
|
||||
fh.seek(0)
|
||||
fh.truncate()
|
||||
@@ -136,6 +171,20 @@ class EncryptionManager:
|
||||
fh.seek(0)
|
||||
encrypted_data = fh.read()
|
||||
|
||||
if encrypted_data.startswith(b"V3:"):
|
||||
# V3 format: V3:<salt(16)><nonce(12)><ciphertext>
|
||||
try:
|
||||
# Salt is used for key derivation externally, so we skip it here
|
||||
nonce = encrypted_data[19:31]
|
||||
ciphertext = encrypted_data[31:]
|
||||
if len(ciphertext) < 16:
|
||||
raise InvalidToken("AES-GCM payload too short")
|
||||
decrypted_data = self.cipher.decrypt(nonce, ciphertext, None)
|
||||
return decrypted_data.decode("utf-8").strip()
|
||||
except InvalidTag as e:
|
||||
logger.error("AES-GCM decryption failed for V3 parent seed.")
|
||||
raise InvalidToken("AES-GCM decryption failed.") from e
|
||||
|
||||
is_legacy = not encrypted_data.startswith(b"V2:")
|
||||
decrypted_data = self.decrypt_data(encrypted_data)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from termcolor import colored
|
||||
from utils.color_scheme import color_text
|
||||
from utils.input_utils import timed_input
|
||||
|
||||
from .encryption import EncryptionManager
|
||||
from .encryption import EncryptionManager, get_salt_from_parent_seed_file
|
||||
from .entry_management import EntryManager
|
||||
from .password_generation import PasswordGenerator
|
||||
from .backup import BackupManager
|
||||
@@ -508,15 +508,34 @@ class PasswordManager:
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
|
||||
# Check for existing salt in parent_seed.enc (V3 format)
|
||||
parent_seed_file = fingerprint_dir / "parent_seed.enc"
|
||||
salt = get_salt_from_parent_seed_file(parent_seed_file)
|
||||
|
||||
print("Deriving key...")
|
||||
if mode == "argon2":
|
||||
seed_key = derive_key_from_password_argon2(password)
|
||||
seed_key = derive_key_from_password_argon2(password, salt=salt)
|
||||
else:
|
||||
seed_key = derive_key_from_password(password, iterations=iterations)
|
||||
seed_key = derive_key_from_password(password, salt=salt, iterations=iterations)
|
||||
|
||||
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||
print("Decrypting seed...")
|
||||
try:
|
||||
self.parent_seed = seed_mgr.decrypt_parent_seed()
|
||||
|
||||
# If the file didn't have a salt (legacy/V2), upgrade it to V3
|
||||
if not salt:
|
||||
logging.info("Upgrading parent seed encryption to V3 (with salt).")
|
||||
new_salt = os.urandom(16)
|
||||
if mode == "argon2":
|
||||
new_seed_key = derive_key_from_password_argon2(password, salt=new_salt)
|
||||
else:
|
||||
new_seed_key = derive_key_from_password(password, salt=new_salt, iterations=iterations)
|
||||
|
||||
new_seed_mgr = EncryptionManager(new_seed_key, fingerprint_dir)
|
||||
new_seed_mgr.encrypt_parent_seed(self.parent_seed, salt=new_salt)
|
||||
|
||||
except Exception:
|
||||
msg = (
|
||||
"Invalid password for selected seed profile. Please try again."
|
||||
@@ -577,10 +596,15 @@ class PasswordManager:
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
|
||||
parent_seed_file = fingerprint_dir / "parent_seed.enc"
|
||||
salt = get_salt_from_parent_seed_file(parent_seed_file)
|
||||
|
||||
if mode == "argon2":
|
||||
seed_key = derive_key_from_password_argon2(password)
|
||||
seed_key = derive_key_from_password_argon2(password, salt=salt)
|
||||
else:
|
||||
seed_key = derive_key_from_password(password, iterations=iterations)
|
||||
seed_key = derive_key_from_password(password, salt=salt, iterations=iterations)
|
||||
|
||||
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||
self.parent_seed = seed_mgr.decrypt_parent_seed()
|
||||
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
|
||||
@@ -745,14 +769,6 @@ class PasswordManager:
|
||||
if password is None:
|
||||
password = prompt_existing_password("Enter your login password: ")
|
||||
|
||||
# Derive encryption key from password
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
key = derive_key_from_password(password, iterations=iterations)
|
||||
|
||||
# Initialize FingerprintManager if not already initialized
|
||||
if not self.fingerprint_manager:
|
||||
self.initialize_fingerprint_manager()
|
||||
@@ -791,6 +807,17 @@ class PasswordManager:
|
||||
print(colored("Error: Seed profile directory not found.", "red"))
|
||||
sys.exit(1)
|
||||
|
||||
# Derive encryption key from password, now that we have the directory to check for salt
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
parent_seed_file = fingerprint_dir / "parent_seed.enc"
|
||||
salt = get_salt_from_parent_seed_file(parent_seed_file)
|
||||
|
||||
key = derive_key_from_password(password, salt=salt, iterations=iterations)
|
||||
|
||||
# Initialize EncryptionManager with key and fingerprint_dir
|
||||
self.encryption_manager = EncryptionManager(key, fingerprint_dir)
|
||||
self.vault = Vault(self.encryption_manager, fingerprint_dir)
|
||||
@@ -926,7 +953,10 @@ class PasswordManager:
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
seed_key = derive_key_from_password(password, iterations=iterations)
|
||||
|
||||
# Generate new salt for new profile
|
||||
salt = os.urandom(16)
|
||||
seed_key = derive_key_from_password(password, salt=salt, iterations=iterations)
|
||||
|
||||
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
|
||||
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||
@@ -937,7 +967,7 @@ class PasswordManager:
|
||||
fingerprint_dir=fingerprint_dir,
|
||||
)
|
||||
|
||||
seed_mgr.encrypt_parent_seed(parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(parent_seed, salt=salt)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.store_hashed_password(password)
|
||||
@@ -1076,7 +1106,10 @@ class PasswordManager:
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
)
|
||||
seed_key = derive_key_from_password(password, iterations=iterations)
|
||||
|
||||
# Generate new salt for new seed
|
||||
salt = os.urandom(16)
|
||||
seed_key = derive_key_from_password(password, salt=salt, iterations=iterations)
|
||||
|
||||
self.encryption_manager = EncryptionManager(index_key, fingerprint_dir)
|
||||
seed_mgr = EncryptionManager(seed_key, fingerprint_dir)
|
||||
@@ -1093,7 +1126,7 @@ class PasswordManager:
|
||||
self.store_hashed_password(password)
|
||||
logging.info("User password hashed and stored successfully.")
|
||||
|
||||
seed_mgr.encrypt_parent_seed(seed)
|
||||
seed_mgr.encrypt_parent_seed(seed, salt=salt)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.parent_seed = seed # Ensure this is a string
|
||||
@@ -4410,12 +4443,15 @@ class PasswordManager:
|
||||
new_key = derive_index_key(self.parent_seed)
|
||||
|
||||
iterations = self.config_manager.get_kdf_iterations()
|
||||
seed_key = derive_key_from_password(new_password, iterations=iterations)
|
||||
|
||||
# Generate new salt for changed password
|
||||
salt = os.urandom(16)
|
||||
seed_key = derive_key_from_password(new_password, salt=salt, iterations=iterations)
|
||||
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
|
||||
|
||||
new_enc_mgr = EncryptionManager(new_key, self.fingerprint_dir)
|
||||
|
||||
seed_mgr.encrypt_parent_seed(self.parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(self.parent_seed, salt=salt)
|
||||
self.vault.set_encryption_manager(new_enc_mgr)
|
||||
self.vault.save_index(index_data)
|
||||
self.config_manager.vault = self.vault
|
||||
|
||||
@@ -65,7 +65,9 @@ def test_setup_encryption_manager_kdf_modes(monkeypatch):
|
||||
if mode == "argon2":
|
||||
monkeypatch.setattr(
|
||||
"seedpass.core.manager.derive_key_from_password_argon2",
|
||||
lambda pw: derive_key_from_password_argon2(pw, **argon_kwargs),
|
||||
lambda pw, salt=None: derive_key_from_password_argon2(
|
||||
pw, salt=salt if salt is not None else b"\x00" * 16, **argon_kwargs
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(PasswordManager, "initialize_bip85", lambda self: None)
|
||||
monkeypatch.setattr(
|
||||
|
||||
@@ -45,7 +45,9 @@ DEFAULT_ENCRYPTION_MODE = EncryptionMode.SEED_ONLY
|
||||
TOTP_PURPOSE = 39
|
||||
|
||||
|
||||
def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
||||
def derive_key_from_password(
|
||||
password: str, salt: bytes = b"", iterations: int = 100_000
|
||||
) -> bytes:
|
||||
"""
|
||||
Derives a Fernet-compatible encryption key from the provided password using PBKDF2-HMAC-SHA256.
|
||||
|
||||
@@ -55,6 +57,7 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
||||
|
||||
Parameters:
|
||||
password (str): The user's password.
|
||||
salt (bytes, optional): A salt for key derivation. Defaults to b"" (empty).
|
||||
iterations (int, optional): Number of iterations for the PBKDF2 algorithm. Defaults to 100,000.
|
||||
|
||||
Returns:
|
||||
@@ -80,7 +83,7 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
||||
key = hashlib.pbkdf2_hmac(
|
||||
hash_name="sha256",
|
||||
password=password_bytes,
|
||||
salt=b"", # No salt for deterministic key derivation
|
||||
salt=salt, # Use provided salt (default empty for backward compatibility)
|
||||
iterations=iterations,
|
||||
dklen=32, # 256-bit key for Fernet
|
||||
)
|
||||
@@ -99,6 +102,7 @@ def derive_key_from_password(password: str, iterations: int = 100_000) -> bytes:
|
||||
|
||||
def derive_key_from_password_argon2(
|
||||
password: str,
|
||||
salt: bytes = b"\x00" * 16,
|
||||
*,
|
||||
time_cost: int = 2,
|
||||
memory_cost: int = 64 * 1024,
|
||||
@@ -106,8 +110,8 @@ def derive_key_from_password_argon2(
|
||||
) -> bytes:
|
||||
"""Derive an encryption key from a password using Argon2id.
|
||||
|
||||
The defaults follow recommended parameters but omit a salt for deterministic
|
||||
output. Smaller values may be supplied for testing.
|
||||
The defaults follow recommended parameters but use a default salt for deterministic
|
||||
output (backward compatibility). Smaller values may be supplied for testing.
|
||||
"""
|
||||
|
||||
if not password:
|
||||
@@ -120,7 +124,7 @@ def derive_key_from_password_argon2(
|
||||
|
||||
key = hash_secret_raw(
|
||||
secret=normalized,
|
||||
salt=b"\x00" * 16,
|
||||
salt=salt,
|
||||
time_cost=time_cost,
|
||||
memory_cost=memory_cost,
|
||||
parallelism=parallelism,
|
||||
|
||||
Reference in new issue
Block a user