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
def hash_password(password: str) -> str:
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.

def verify_password(password: str, password_hash: str) -> bool:
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.