Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 75 additions & 1 deletion pacli/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -237,12 +238,85 @@
)
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}"

Comment on lines +241 to +256
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)
Comment on lines +257 to +266
Comment on lines +259 to +266
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

Comment on lines +270 to +295
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
Comment on lines +305 to +319
Comment on lines +316 to +319
# Fallback: attempt decryption
salt = get_salt()
test_fernet = self._derive_fernet(password, salt)
Expand Down
Loading