diff --git a/pacli/store.py b/pacli/store.py index bf3ab8b..ef3344f 100644 --- a/pacli/store.py +++ b/pacli/store.py @@ -5,6 +5,7 @@ import sqlite3 import threading import hashlib +import hmac from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes @@ -237,12 +238,85 @@ def get_secrets_by_label(self, label): ) return results + PBKDF2_ITERATIONS = 310000 + PBKDF2_SALT_BYTES = 16 + + def _hash_password_pbkdf2(self, password: str, salt: bytes = None) -> str: + if salt is None: + salt = os.urandom(self.PBKDF2_SALT_BYTES) + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode(), + salt, + self.PBKDF2_ITERATIONS, + ) + salt_b64 = base64.b64encode(salt).decode("utf-8") + dk_b64 = base64.b64encode(dk).decode("utf-8") + return f"pbkdf2_sha256${self.PBKDF2_ITERATIONS}${salt_b64}${dk_b64}" + + def _verify_password_pbkdf2(self, password: str, stored: str) -> bool: + try: + scheme, iter_str, salt_b64, expected_b64 = stored.split("$", 3) + if scheme != "pbkdf2_sha256": + return False + iterations = int(iter_str) + salt = base64.b64decode(salt_b64.encode("utf-8")) + expected = base64.b64decode(expected_b64.encode("utf-8")) + actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations) + return hmac.compare_digest(actual, expected) + except Exception: + return False + + def _hash_legacy_sha256_with_pbkdf2(self, legacy_sha256_hex: str, salt: bytes = None) -> str: + if salt is None: + salt = os.urandom(self.PBKDF2_SALT_BYTES) + dk = hashlib.pbkdf2_hmac( + "sha256", + legacy_sha256_hex.encode("utf-8"), + salt, + self.PBKDF2_ITERATIONS, + ) + salt_b64 = base64.b64encode(salt).decode("utf-8") + dk_b64 = base64.b64encode(dk).decode("utf-8") + return f"legacy_pbkdf2_sha256${self.PBKDF2_ITERATIONS}${salt_b64}${dk_b64}" + + def _verify_legacy_sha256_with_pbkdf2(self, legacy_sha256_hex: str, stored: str) -> bool: + try: + scheme, iter_str, salt_b64, expected_b64 = stored.split("$", 3) + if scheme != "legacy_pbkdf2_sha256": + return False + iterations = int(iter_str) + salt = base64.b64decode(salt_b64.encode("utf-8")) + expected = base64.b64decode(expected_b64.encode("utf-8")) + actual = hashlib.pbkdf2_hmac("sha256", legacy_sha256_hex.encode("utf-8"), salt, iterations) + return hmac.compare_digest(actual, expected) + except Exception: + return False + def verify_master_password(self, password): try: if os.path.exists(PASSWORD_HASH_PATH): with open(PASSWORD_HASH_PATH, "r") as f: stored_hash = f.read().strip() - return hashlib.sha256(password.encode()).hexdigest() == stored_hash + + if stored_hash.startswith("pbkdf2_sha256$"): + return self._verify_password_pbkdf2(password, stored_hash) + + # Legacy fallback: support old SHA-256 hashes and transparently upgrade. + legacy_ok = False + + legacy_sha256 = hashlib.sha256(password.encode()).hexdigest() + + if stored_hash.startswith("legacy_pbkdf2_sha256$"): + legacy_ok = self._verify_legacy_sha256_with_pbkdf2(legacy_sha256, stored_hash) + else: + # One-time migration for existing raw SHA-256 legacy values. + legacy_ok = hmac.compare_digest(stored_hash, legacy_sha256) + + if legacy_ok: + with open(PASSWORD_HASH_PATH, "w") as f: + f.write(self._hash_password_pbkdf2(password)) + return legacy_ok # Fallback: attempt decryption salt = get_salt() test_fernet = self._derive_fernet(password, salt)