mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-08 13:52:29 +00:00
Harden security item 2: KDF policy, export safeguards, and tests
This commit is contained in:
1 parent
c3eef44ab7
commit
fcbe2b2943
13 files changed
+290
-126
No files matched your search
@@ -25,6 +25,8 @@ class ConfigManager:
|
||||
"""Manage per-profile configuration encrypted on disk."""
|
||||
|
||||
CONFIG_FILENAME = "seedpass_config.json.enc"
|
||||
DEFAULT_PBKDF2_ITERATIONS = 200_000
|
||||
LEGACY_PBKDF2_ITERATION_FALLBACKS = (50_000, 100_000)
|
||||
|
||||
def __init__(self, vault: Vault, fingerprint_dir: Path):
|
||||
self.vault = vault
|
||||
@@ -48,7 +50,7 @@ class ConfigManager:
|
||||
"pin_hash": "",
|
||||
"password_hash": "",
|
||||
"inactivity_timeout": INACTIVITY_TIMEOUT,
|
||||
"kdf_iterations": 50_000,
|
||||
"kdf_iterations": self.DEFAULT_PBKDF2_ITERATIONS,
|
||||
"kdf_mode": "pbkdf2",
|
||||
"argon2_time_cost": 2,
|
||||
"additional_backup_path": "",
|
||||
@@ -78,7 +80,7 @@ class ConfigManager:
|
||||
data.setdefault("pin_hash", "")
|
||||
data.setdefault("password_hash", "")
|
||||
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
|
||||
data.setdefault("kdf_iterations", 50_000)
|
||||
data.setdefault("kdf_iterations", self.DEFAULT_PBKDF2_ITERATIONS)
|
||||
data.setdefault("kdf_mode", "pbkdf2")
|
||||
data.setdefault("argon2_time_cost", 2)
|
||||
data.setdefault("additional_backup_path", "")
|
||||
@@ -186,7 +188,7 @@ class ConfigManager:
|
||||
def get_kdf_iterations(self) -> int:
|
||||
"""Retrieve the PBKDF2 iteration count."""
|
||||
config = self.load_config(require_pin=False)
|
||||
return int(config.get("kdf_iterations", 50_000))
|
||||
return int(config.get("kdf_iterations", self.DEFAULT_PBKDF2_ITERATIONS))
|
||||
|
||||
def set_kdf_mode(self, mode: str) -> None:
|
||||
"""Persist the key derivation function mode."""
|
||||
|
||||
@@ -16,7 +16,6 @@ except Exception: # pragma: no cover - fallback for environments without orjson
|
||||
import hashlib
|
||||
import os
|
||||
import base64
|
||||
import zlib
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
@@ -93,21 +92,14 @@ class EncryptionManager:
|
||||
# Track user preference for handling legacy indexes
|
||||
self._legacy_migrate_flag = True
|
||||
self.last_migration_performed = False
|
||||
# Track nonces to detect accidental reuse
|
||||
self.nonce_crc_table: set[int] = set()
|
||||
|
||||
def encrypt_data(self, data: bytes) -> bytes:
|
||||
"""
|
||||
Encrypt data using AES-GCM, emitting ``b"V3|" + nonce + ciphertext + tag``.
|
||||
A fresh 96-bit nonce is generated for each call and tracked via a CRC
|
||||
table to detect accidental reuse during batch operations.
|
||||
A fresh 96-bit nonce is generated for each call.
|
||||
"""
|
||||
try:
|
||||
nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM
|
||||
crc = zlib.crc32(nonce)
|
||||
if crc in self.nonce_crc_table:
|
||||
raise ValueError("Nonce reuse detected")
|
||||
self.nonce_crc_table.add(crc)
|
||||
ciphertext = self.cipher.encrypt(nonce, data, None)
|
||||
return b"V3|" + nonce + ciphertext
|
||||
except Exception as e:
|
||||
|
||||
@@ -358,9 +358,17 @@ class PasswordManager:
|
||||
return "pbkdf2"
|
||||
|
||||
def _get_kdf_iterations(self) -> int:
|
||||
floor = getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
if getattr(self, "config_manager", None):
|
||||
return self.config_manager.get_kdf_iterations()
|
||||
return 50_000
|
||||
configured = int(self.config_manager.get_kdf_iterations())
|
||||
if configured < floor:
|
||||
logger.warning(
|
||||
"Configured PBKDF2 iterations (%s) is below policy floor (%s); using floor.",
|
||||
configured,
|
||||
floor,
|
||||
)
|
||||
return max(configured, floor)
|
||||
return floor
|
||||
|
||||
def _get_argon2_time_cost(self) -> int:
|
||||
if getattr(self, "config_manager", None):
|
||||
@@ -397,6 +405,37 @@ class PasswordManager:
|
||||
)
|
||||
return derive_key_from_password(password, fingerprint, iterations=iter_count)
|
||||
|
||||
def _build_seed_kdf_config(
|
||||
self,
|
||||
fingerprint: str,
|
||||
*,
|
||||
mode: str | None = None,
|
||||
iterations: int | None = None,
|
||||
) -> KdfConfig:
|
||||
"""Return the metadata block describing seed-key derivation policy."""
|
||||
|
||||
chosen_mode = mode or self._get_kdf_mode()
|
||||
salt = hashlib.sha256(fingerprint.encode()).digest()[:16]
|
||||
salt_b64 = base64.b64encode(salt).decode()
|
||||
if chosen_mode == "argon2":
|
||||
return KdfConfig(
|
||||
name="argon2id",
|
||||
params={
|
||||
"time_cost": self._get_argon2_time_cost(),
|
||||
"memory_cost": 64 * 1024,
|
||||
"parallelism": 8,
|
||||
},
|
||||
salt_b64=salt_b64,
|
||||
)
|
||||
iter_count = (
|
||||
int(iterations) if iterations is not None else self._get_kdf_iterations()
|
||||
)
|
||||
return KdfConfig(
|
||||
name="pbkdf2",
|
||||
params={"iterations": iter_count},
|
||||
salt_b64=salt_b64,
|
||||
)
|
||||
|
||||
def ensure_key_hierarchy(self) -> None:
|
||||
"""Ensure sub-keys are derived from the current parent seed."""
|
||||
if (
|
||||
@@ -766,14 +805,20 @@ class PasswordManager:
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
else getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
)
|
||||
print("Deriving key...")
|
||||
salt_fp = fingerprint_dir.name
|
||||
|
||||
iter_candidates: list[int] = [iterations]
|
||||
if mode != "argon2":
|
||||
iter_candidates.extend([50_000, 100_000])
|
||||
iter_candidates.extend(
|
||||
getattr(
|
||||
ConfigManager,
|
||||
"LEGACY_PBKDF2_ITERATION_FALLBACKS",
|
||||
(50_000, 100_000),
|
||||
)
|
||||
)
|
||||
|
||||
seed_mgr: EncryptionManager | None = None
|
||||
for iter_try in dict.fromkeys(iter_candidates):
|
||||
@@ -854,7 +899,7 @@ class PasswordManager:
|
||||
iterations = (
|
||||
self.config_manager.get_kdf_iterations()
|
||||
if getattr(self, "config_manager", None)
|
||||
else 50_000
|
||||
else getattr(ConfigManager, "DEFAULT_PBKDF2_ITERATIONS", 200_000)
|
||||
)
|
||||
salt_fp = fingerprint_dir.name
|
||||
seed_key = self._derive_seed_key(
|
||||
@@ -1167,7 +1212,10 @@ class PasswordManager:
|
||||
fingerprint_dir=fingerprint_dir,
|
||||
)
|
||||
|
||||
seed_mgr.encrypt_parent_seed(parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
parent_seed,
|
||||
kdf=self._build_seed_kdf_config(fingerprint),
|
||||
)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.store_hashed_password(password)
|
||||
@@ -1364,7 +1412,13 @@ 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,
|
||||
kdf=self._build_seed_kdf_config(
|
||||
fingerprint_dir.name,
|
||||
mode=self._get_kdf_mode(),
|
||||
),
|
||||
)
|
||||
logging.info("Parent seed encrypted and saved successfully.")
|
||||
|
||||
self.parent_seed = seed # Ensure this is a string
|
||||
@@ -4212,10 +4266,28 @@ class PasswordManager:
|
||||
parent_fingerprint=parent_fp,
|
||||
child_fingerprint=child_fp,
|
||||
)
|
||||
if encrypt is None:
|
||||
encrypt = not confirm_action(
|
||||
"Export database without encryption? (Y/N): "
|
||||
if encrypt is False:
|
||||
logger.warning("Exporting database in plaintext mode")
|
||||
self.notify(
|
||||
"Warning: plaintext export selected; protect and delete the file quickly.",
|
||||
level="WARNING",
|
||||
)
|
||||
if encrypt is None:
|
||||
if confirm_action("Export database without encryption? (Y/N): "):
|
||||
print(
|
||||
colored(
|
||||
"Warning: plaintext export contains all secrets in clear text.",
|
||||
"red",
|
||||
)
|
||||
)
|
||||
if not confirm_action(
|
||||
"Confirm plaintext export (high risk). Continue? (Y/N): "
|
||||
):
|
||||
print(colored("Export cancelled.", "yellow"))
|
||||
return None
|
||||
encrypt = False
|
||||
else:
|
||||
encrypt = True
|
||||
path = export_backup(
|
||||
self.vault,
|
||||
self.backup_manager,
|
||||
@@ -4226,7 +4298,13 @@ class PasswordManager:
|
||||
print(colored(f"Database exported to '{path}'.", "green"))
|
||||
audit_logger = getattr(self, "audit_logger", None)
|
||||
if audit_logger and path is not None:
|
||||
audit_logger.log("backup_export", {"path": str(path)})
|
||||
audit_logger.log(
|
||||
"backup_export",
|
||||
{
|
||||
"path": str(path),
|
||||
"encryption_mode": "encrypted" if encrypt else "none",
|
||||
},
|
||||
)
|
||||
return path
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to export database: {e}", exc_info=True)
|
||||
@@ -4301,7 +4379,10 @@ class PasswordManager:
|
||||
password = prompt_new_password()
|
||||
seed_key = self._derive_seed_key(password, self.current_fingerprint)
|
||||
seed_mgr = EncryptionManager(seed_key, self.fingerprint_dir)
|
||||
seed_mgr.encrypt_parent_seed(self.parent_seed)
|
||||
seed_mgr.encrypt_parent_seed(
|
||||
self.parent_seed,
|
||||
kdf=self._build_seed_kdf_config(self.current_fingerprint),
|
||||
)
|
||||
self.store_hashed_password(password)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
@@ -4603,7 +4684,10 @@ class PasswordManager:
|
||||
|
||||
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,
|
||||
kdf=self._build_seed_kdf_config(self.current_fingerprint),
|
||||
)
|
||||
self.vault.set_encryption_manager(new_enc_mgr)
|
||||
self.vault.save_index(index_data)
|
||||
self.config_manager.vault = self.vault
|
||||
|
||||
Reference in new issue
Block a user