Authenticate the node dashboard, and the broker connection per node
CI / lint (push) Failing after 24s
CI / test (push) Failing after 28s
Build edge-node / build (push) Failing after 43s
Build op25 / build (push) Failing after 47s

Two unauthenticated surfaces closed on the edge node.

Dashboard and API: the local dashboard and every /api/* route were open to
anything on the node's LAN. Adds a login page plus session-cookie auth for
the browser, and cookie-or-Basic for the API so scripted callers stay
possible. Passwords are hashed with stdlib scrypt (no new dependency, this
runs on a Pi) and compared in constant time; the salt and session-signing
secret persist in credentials.json. Startup warns while the default password
is still in place. No non-browser callers of the node API exist today
(C2 talks to nodes over MQTT and nodes call C2 outbound), so nothing breaks.

Adds python-multipart, which FastAPI's Form() needs for the login POST and
which was missing from requirements entirely.

MQTT: nodes authenticated with a shared drb-node password, and the broker
ACL keyed off %c — the client-supplied client id — so any holder of that one
password could claim another node's topic namespace. Nodes now connect as
username=<node_id>, password=<their C2-issued api_key>, which mosquitto's
dynamic-security plugin checks, with the ACL keyed off the authenticated %u.
TLS is gated on MQTT_TLS and uses default CA verification.

The old key_request MQTT path stays in place behind TODO(mqtt-cutover)
markers as the fallback until the cutover is proven; a node with no api_key
on disk logs a clear repeated refusal rather than spinning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-08-16 09:34:16 -04:00
co-authored by Claude Opus 5
parent a61a7b2c31
commit 87633ab50d
14 changed files with 1109 additions and 17 deletions
+57 -5
View File
@@ -1,30 +1,73 @@
"""
Manages the persisted node API key.
Manages the persisted node API key, plus the local-auth signing material used
by app/internal/auth.py.
The key is provisioned by the C2 server after an admin approves the node.
The API key is provisioned by the C2 server after an admin approves the node.
It arrives via MQTT and is saved to /configs/credentials.json so it survives
container restarts.
The scrypt salt and session-signing secret are generated locally on first boot
(never provisioned externally) and persisted the same way, so dashboard
sessions survive a container restart instead of forcing every operator to
re-login whenever the node restarts.
"""
import json
import secrets
from pathlib import Path
from app.config import settings
from app.internal.logger import logger
_CREDS_FILE = Path(settings.config_path) / "credentials.json"
_api_key: str | None = None
_auth_salt: bytes | None = None
_session_secret: bytes | None = None
def load() -> None:
"""Load persisted credentials from disk on startup."""
global _api_key
global _api_key, _auth_salt, _session_secret
if _CREDS_FILE.exists():
try:
data = json.loads(_CREDS_FILE.read_text())
_api_key = data.get("api_key")
if data.get("auth_salt"):
_auth_salt = bytes.fromhex(data["auth_salt"])
if data.get("session_secret"):
_session_secret = bytes.fromhex(data["session_secret"])
if _api_key:
logger.info("Node credentials loaded from disk.")
except Exception as e:
logger.warning(f"Could not read credentials file: {e}")
_ensure_auth_material()
def _ensure_auth_material() -> None:
"""Generate (once) and persist the local-auth salt + session secret."""
global _auth_salt, _session_secret
changed = False
if _auth_salt is None:
_auth_salt = secrets.token_bytes(16)
changed = True
if _session_secret is None:
_session_secret = secrets.token_bytes(32)
changed = True
if changed:
_write()
logger.info("Generated local-auth signing material (first boot).")
def get_auth_salt() -> bytes:
"""Scrypt salt for dashboard password hashing — generated once, persisted."""
if _auth_salt is None:
_ensure_auth_material()
return _auth_salt # type: ignore[return-value]
def get_session_secret() -> bytes:
"""HMAC key used to sign dashboard session cookies — generated once, persisted."""
if _session_secret is None:
_ensure_auth_material()
return _session_secret # type: ignore[return-value]
def get_api_key() -> str | None:
@@ -34,6 +77,15 @@ def get_api_key() -> str | None:
def save_api_key(key: str) -> None:
global _api_key
_api_key = key
_CREDS_FILE.parent.mkdir(parents=True, exist_ok=True)
_CREDS_FILE.write_text(json.dumps({"api_key": key}))
_write()
logger.info("Node API key saved to disk.")
def _write() -> None:
_CREDS_FILE.parent.mkdir(parents=True, exist_ok=True)
data: dict = {"api_key": _api_key}
if _auth_salt is not None:
data["auth_salt"] = _auth_salt.hex()
if _session_secret is not None:
data["session_secret"] = _session_secret.hex()
_CREDS_FILE.write_text(json.dumps(data))