All checks were successful
Build ISO / build-iso (push) Successful in 17m28s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 59s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m38s
Three interlocking issues that made 26.11/26.12 effectively un-upgradable from pre-auth versions without manual pacman + symlink surgery. Caught while SSH-testing the .196 VM which landed on a rollback loop after every Update-now click. 1. auth.py imported werkzeug.security, but the target system runs core as bare system Python — neither flask nor werkzeug are pip-installed. Fresh 26.11+ boxes died on import. Replaced with a 50-line stdlib `furtka/passwd.py` using hashlib.pbkdf2_hmac for new hashes and parsing werkzeug's `scrypt:N:r:p$salt$hex` format for backward-read so existing users.json survives. 2. updater._health_check pinged /api/apps expecting 200. Post- auth, /api/apps returns 401 for unauth requests → HTTPError caught as URLError → retry loop → 30s timeout → rollback. Now any 2xx-4xx counts as "server alive"; only 5xx / connection errors fail. Server responding at all is proof it came back up. 3. _do_install released the fcntl lock between sync pre-validation and the systemd-run dispatch. A second POST could slip in, pass the lock check, return 202, and leave its install-bg child to die silently on the in-child lock. Now the API also reads install-state.json and refuses 409 on non-terminal stages — the state file is the reliable signal, the fcntl lock is defence in depth. Test coverage: - tests/test_passwd.py (new, 6 cases): roundtrip, salt uniqueness, format shape, werkzeug scrypt backward-compat against a real hash captured from the .196 box, malformed + non-string rejection. - tests/test_updater.py: +3 cases for _health_check — 4xx=healthy, 5xx=unhealthy, URLError retry loop. - tests/test_api.py: +2 cases for install 409 on non-terminal state + 202 after terminal. All 267 tests green, ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
"""Stdlib-only password hashing, compatible with werkzeug's hash format.
|
|
|
|
Why this exists: 26.11-alpha introduced auth via ``werkzeug.security``,
|
|
but the target system doesn't have ``werkzeug`` installed (Core runs as
|
|
system Python with only the stdlib — pyproject.toml's ``flask>=3.0``
|
|
dep is never pip-installed on the box). Fresh installs from a 26.11 /
|
|
26.12 ISO crashed on import; upgrades from pre-auth versions were
|
|
double-broken by that plus a too-strict updater health check.
|
|
|
|
Fix: replace werkzeug with stdlib equivalents using the same hash
|
|
**format** so existing ``users.json`` files created by 26.11 / 26.12 on
|
|
the rare boxes that happened to have werkzeug installed (Medion, .196
|
|
after manual pacman) still verify.
|
|
|
|
Format: ``<method>$<salt>$<hex digest>``
|
|
- ``pbkdf2:<hash>:<iterations>`` — what we generate by default here
|
|
- ``scrypt:<N>:<r>:<p>`` — what werkzeug's default produces
|
|
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.
|
|
|
|
Accepts both our own pbkdf2 hashes and legacy werkzeug scrypt
|
|
hashes in ``scrypt:N:r:p$salt$hex`` form — so users.json files
|
|
written by 26.11 / 26.12 keep working after upgrade.
|
|
"""
|
|
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":
|
|
# werkzeug: scrypt:N:r:p, dklen=64, maxmem=132 MiB. Without
|
|
# the explicit maxmem we'd hit OpenSSL's default memory cap
|
|
# and throw ValueError on N >= 32768.
|
|
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)
|