jmri_server.auth.passwords
Password hashing via bcrypt.
Isolated into its own module so callers that only need models or
YAML I/O don't pull the bcrypt dependency transitively (matters
for lean CLI / test builds).
Uses bcrypt with the library default work factor (12 rounds today). That takes ~200ms per verify on a Raspberry Pi 4, which is fine for the throughput a hobby-scale server sees and provides adequate resistance to offline attacks.
The wrapper functions accept str on the public API and handle
UTF-8 encoding internally — every real-world password store deals
with strings, and putting the bytes conversion at the boundary
keeps the callers clean.
1"""Password hashing via bcrypt. 2 3Isolated into its own module so callers that only need models or 4YAML I/O don't pull the ``bcrypt`` dependency transitively (matters 5for lean CLI / test builds). 6 7Uses bcrypt with the library default work factor (12 rounds today). 8That takes ~200ms per verify on a Raspberry Pi 4, which is fine for 9the throughput a hobby-scale server sees and provides adequate 10resistance to offline attacks. 11 12The wrapper functions accept ``str`` on the public API and handle 13UTF-8 encoding internally — every real-world password store deals 14with strings, and putting the bytes conversion at the boundary 15keeps the callers clean. 16""" 17from __future__ import annotations 18 19import bcrypt 20 21 22def hash_password(password: str) -> str: 23 """Return a bcrypt hash of *password* suitable for storage. 24 25 The returned string embeds the salt + work factor + hash, so 26 :func:`verify_password` doesn't need any external state to 27 validate. Idempotent-safe: repeated calls with the same 28 password produce different hashes (different salts) — verify 29 handles both. 30 """ 31 if not password: 32 raise ValueError("password must be non-empty") 33 salted = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) 34 return salted.decode("utf-8") 35 36 37def verify_password(password: str, password_hash: str) -> bool: 38 """Constant-time check of *password* against *password_hash*. 39 40 Returns ``False`` for an empty ``password_hash`` — a user with 41 no hash on file has no valid password, and short-circuiting to 42 ``False`` avoids leaking that state via bcrypt error messages. 43 """ 44 if not password_hash: 45 return False 46 try: 47 return bcrypt.checkpw( 48 password.encode("utf-8"), 49 password_hash.encode("utf-8"), 50 ) 51 except (ValueError, TypeError): 52 # Malformed hash — treat as no match rather than crashing. 53 # Real users see this after a hand-edit of users.yaml gone 54 # wrong; the auth log will show why they can't log in. 55 return False
23def hash_password(password: str) -> str: 24 """Return a bcrypt hash of *password* suitable for storage. 25 26 The returned string embeds the salt + work factor + hash, so 27 :func:`verify_password` doesn't need any external state to 28 validate. Idempotent-safe: repeated calls with the same 29 password produce different hashes (different salts) — verify 30 handles both. 31 """ 32 if not password: 33 raise ValueError("password must be non-empty") 34 salted = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) 35 return salted.decode("utf-8")
Return a bcrypt hash of password suitable for storage.
The returned string embeds the salt + work factor + hash, so
verify_password() doesn't need any external state to
validate. Idempotent-safe: repeated calls with the same
password produce different hashes (different salts) — verify
handles both.
38def verify_password(password: str, password_hash: str) -> bool: 39 """Constant-time check of *password* against *password_hash*. 40 41 Returns ``False`` for an empty ``password_hash`` — a user with 42 no hash on file has no valid password, and short-circuiting to 43 ``False`` avoids leaking that state via bcrypt error messages. 44 """ 45 if not password_hash: 46 return False 47 try: 48 return bcrypt.checkpw( 49 password.encode("utf-8"), 50 password_hash.encode("utf-8"), 51 ) 52 except (ValueError, TypeError): 53 # Malformed hash — treat as no match rather than crashing. 54 # Real users see this after a hand-edit of users.yaml gone 55 # wrong; the auth log will show why they can't log in. 56 return False
Constant-time check of password against password_hash.
Returns False for an empty password_hash — a user with
no hash on file has no valid password, and short-circuiting to
False avoids leaking that state via bcrypt error messages.