**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.
**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).
**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.
**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.
**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.
**Next pending phase**: `12_nut_self_test_verdict`.
7.2 KiB
Task 01 — Auth primitives: hashes, session tokens, CSRF tokens, throttle
Phase: 11_admin_login · Source: operator request 2026-10-17 — "password protect this dashboard for a single admin user"
Story: n/a
Objective
src/dashboards/auth.py: every authentication decision as a pure, unit-testable function with
no HTTP and no I/O — password hash/verify, session token mint/read, CSRF token mint/verify,
login throttle, origin check. Nothing here knows about FastAPI; task 03 wires it.
Work
-
New
src/dashboards/auth.py, module docstring stating the design and why it is hand-rolled (stdlib only — the same reasoning asprom.pyand the NUT wire client; noitsdangerous, nobcrypt, no session store to operate). Constants at the top:ADMIN_USER = "admin" # single admin; there is no user table and none is coming SESSION_COOKIE = "dash_session" CSRF_HEADER = "X-CSRF-Token" SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) PBKDF2_ITERATIONS = 600_000 # OWASP's PBKDF2-HMAC-SHA256 figure; DASH_PBKDF2_ITERATIONS overrides PBKDF2_ALGO = "pbkdf2_sha256" -
Password verifier — self-describing so the cost can rise later without a migration:
hash_password(password: str, *, iterations: int = PBKDF2_ITERATIONS) -> str→pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>, saltsecrets.token_bytes(16),hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations).verify_password(password: str, encoded: str) -> bool— parses defensively and returnsFalsefor"", a wrong field count, a non-numeric iteration count, an unknown algo name, odd-length hex, or a length mismatch. It must never raise: a corruptmetarow has to read as "wrong password", not a 500 on the login page. Compare withhmac.compare_digest.needs_rehash(encoded: str, *, iterations: int) -> bool— true when the stored cost (or algo) is below the configured one, so a login can transparently upgrade the hash.- No password, hash, salt or session token may ever appear in a log line, an exception message, or a return value used for display. Name this in the docstring: AGENTS.md invariant 6 now covers the admin's own credential.
-
Sessions — stateless and signed, so there is no session table and no cleanup job:
new_secret() -> str→secrets.token_hex(32). Stored by task 02 under the meta keyauth.session_secret; rotating it invalidates every issued cookie, which is how changing the password signs everyone else out.@dataclass(frozen=True) Session: username: str; expires_at: float; token: strmint_session(secret: str, *, username: str = ADMIN_USER, hours: float = 720.0, now: float | None = None) -> str→b64url(f"{username}|{expires_at:.0f}")+"."+b64url(hmac_sha256(secret, payload)). Usebase64.urlsafe_b64encodeand strip padding;hmac.new(secret.encode(), payload.encode(), hashlib.sha256).read_session(secret: str, token: str, *, now: float | None = None) -> Session | None— signature first (constant-time), thenexpires_at > now. ReturnsNonefor:"", no dot, non-base64 payload, payload without|, non-numeric expiry, an expired session, a token signed by a rotated secret, and a payload whose username was tampered with.mint_csrf(secret: str, session_token: str) -> str→hmac_sha256(secret, "csrf|" + session_token).hexdigest()— bound to the session, so a token stolen from one session cannot authorise another's write, and it needs no expiry of its own because the session has one.verify_csrf(secret: str, session_token: str, provided: str | None) -> bool—compare_digest,FalseonNone/empty.
-
Login throttle — in-process memory, because there is one admin and one uvicorn process:
class LoginThrottle: def __init__(self, *, max_attempts: int = 5, base_seconds: float = 60.0, max_seconds: float = 3600.0)retry_after(key: str, *, now: float | None = None) -> float→ seconds still blocked,0.0when allowed.failure(key: str, *, now: float | None = None) -> float→ the backoff just applied. Exponential frombase_secondsper key, capped atmax_seconds; a key that gets a successful login is cleared.success(key: str) -> None, andprune(now)(or an inline length guard) so a spoofed swarm of source addresses cannot grow the dict without bound.- Take
nowas an injectable seconds value on every method (time.monotonicat the call site in task 03) — a test must not sleep 60 s to prove the backoff works. - ASSUMPTION: the key is the socket peer address. Behind nginx every login shares one
bucket, so a determined attacker can lock the operator out of the dashboard (not of the
UPS, not of the data) for at most
max_seconds. Accepted for a single-admin box; it goes inAGENTS.md→ Known gaps rather than becoming anX-Forwarded-Forknob that is trivially spoofed when someone forgets to trust the proxy.
-
Origin / Sec-Fetch gate as pure string functions (unit-testable without a
Request):origin_matches(origin: str, own_origin: str, allowed: Sequence[str] = ()) -> boolsec_fetch_site_allowed(value: str | None) -> bool— allowNone,"same-origin","none"; reject"cross-site","cross-origin","same-site"(unknown → reject). Anything unrecognised is rejected: a browser that invents a new value is a browser doing something we did not reason about.
Testing & Quality
tests/test_auth.py(new) — the exhaustive negative table above. Minimum: round-trip hash/verify, wrong password, unicode password ("pässwörd"— proves the utf-8 encode), five malformed encodings allFalsewith no exception,needs_rehashon a 1_000-iteration hash against the default cost, session round-trip, tampered username, flipped signature byte, truncated token, expired session (vianow), cookie from a rotated secret, CSRF ok/wrong-session/missing, throttle allows 5, blocks the 6th withretry_after > 0, backoff doubles and caps atmax_seconds,success()clears,origin_matchesaccepts its own origin and the allow-list and rejectshttp://evil.example,sec_fetch_site_allowed("cross-site") is False.- Cover
auth.pyto ~100% — every branch here is a security decision, and a branch no test reaches is a decision nobody checked. - Coverage: >90% on this task's new code; overall gate (94%) maintained.
Completion Criteria
uv run pytest tests/test_auth.py -vgreenpython -c "from dashboards import auth; h=auth.hash_password('testpass'); print(h)"prints apbkdf2_sha256$600000$…string andauth.verify_password('testpass', h)isTruewhileverify_password('wrong', h)andverify_password('testpass', 'junk')areFalsegrep -n "print(\|logger\|logging" src/dashboards/auth.pyshows nothing that could echo a credential- no new dependency in
pyproject.toml/uv.lock(uv pip list --outdatedstays empty) uv run pytestgreen;uv run ruff check src tests agent && uv run pyrightclean