83 lines
2.7 KiB
Python
83 lines
2.7 KiB
Python
|
|
"""Stdlib-only secret hashing.
|
||
|
|
|
||
|
|
Vendored from furtka/furtka/furtka/passwd.py — kept byte-for-byte identical
|
||
|
|
to the source rather than reimplemented, the same "own a copy, keep it in
|
||
|
|
lockstep" approach furtka-apps/scripts/vendor/furtka_manifest.py already
|
||
|
|
uses for the manifest schema. Used here to hash registration/box/account
|
||
|
|
bearer tokens at rest, not just user passwords.
|
||
|
|
|
||
|
|
Format: ``<method>$<salt>$<hex digest>``
|
||
|
|
- ``pbkdf2:<hash>:<iterations>`` — what we generate by default here
|
||
|
|
- ``scrypt:<N>:<r>:<p>`` — accepted for parity with the furtka-core
|
||
|
|
copy; never produced by this module
|
||
|
|
Both are implemented via ``hashlib`` which has been stdlib since 3.6.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import hmac
|
||
|
|
import secrets
|
||
|
|
|
||
|
|
_PBKDF2_HASH = "sha256"
|
||
|
|
_PBKDF2_ITERATIONS = 600_000
|
||
|
|
_SALT_LEN = 16
|
||
|
|
|
||
|
|
|
||
|
|
def hash_password(password: str) -> str:
|
||
|
|
"""Return a ``pbkdf2:sha256:<iter>$<salt>$<hex>`` hash of *password*.
|
||
|
|
|
||
|
|
PBKDF2-SHA256 over UTF-8. 600k iterations — same as werkzeug's
|
||
|
|
default in the 3.x series, roughly OWASP 2023's recommendation.
|
||
|
|
"""
|
||
|
|
if not isinstance(password, str):
|
||
|
|
raise TypeError("password must be str")
|
||
|
|
salt = secrets.token_urlsafe(_SALT_LEN)[:_SALT_LEN]
|
||
|
|
dk = hashlib.pbkdf2_hmac(
|
||
|
|
_PBKDF2_HASH, password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS
|
||
|
|
)
|
||
|
|
return f"pbkdf2:{_PBKDF2_HASH}:{_PBKDF2_ITERATIONS}${salt}${dk.hex()}"
|
||
|
|
|
||
|
|
|
||
|
|
def verify_password(password: str, hashed: str) -> bool:
|
||
|
|
"""Constant-time verify *password* against a stored *hashed* value."""
|
||
|
|
if not isinstance(password, str) or not isinstance(hashed, str):
|
||
|
|
return False
|
||
|
|
try:
|
||
|
|
method, salt, expected = hashed.split("$", 2)
|
||
|
|
except ValueError:
|
||
|
|
return False
|
||
|
|
parts = method.split(":")
|
||
|
|
if not parts:
|
||
|
|
return False
|
||
|
|
algo = parts[0]
|
||
|
|
pw_bytes = password.encode("utf-8")
|
||
|
|
salt_bytes = salt.encode("utf-8")
|
||
|
|
try:
|
||
|
|
if algo == "pbkdf2":
|
||
|
|
if len(parts) < 3:
|
||
|
|
return False
|
||
|
|
inner_hash = parts[1]
|
||
|
|
iterations = int(parts[2])
|
||
|
|
dk = hashlib.pbkdf2_hmac(inner_hash, pw_bytes, salt_bytes, iterations)
|
||
|
|
elif algo == "scrypt":
|
||
|
|
if len(parts) < 4:
|
||
|
|
return False
|
||
|
|
n = int(parts[1])
|
||
|
|
r = int(parts[2])
|
||
|
|
p = int(parts[3])
|
||
|
|
dk = hashlib.scrypt(
|
||
|
|
pw_bytes,
|
||
|
|
salt=salt_bytes,
|
||
|
|
n=n,
|
||
|
|
r=r,
|
||
|
|
p=p,
|
||
|
|
dklen=64,
|
||
|
|
maxmem=132 * 1024 * 1024,
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
return False
|
||
|
|
except (ValueError, TypeError, OverflowError):
|
||
|
|
return False
|
||
|
|
return hmac.compare_digest(dk.hex(), expected)
|