diff --git a/.env.example b/.env.example index c11be14..0549c4a 100644 --- a/.env.example +++ b/.env.example @@ -5,11 +5,32 @@ NODE_LAT=0.0 NODE_LON=0.0 # MQTT — point to your C2 server +# +# Post-cutover (MQTT-PUBLIC-AUTH-PLAN.md dynsec revision): there is no shared +# node login any more. The node authenticates as username=NODE_ID, +# password= automatically — nothing to set +# here for that; the api_key is provisioned via MQTT after an admin approves +# the node (see credentials.json) and does not go in this file. +# +# Local/dev, pointed at a plaintext broker on :1883: leave MQTT_TLS unset. MQTT_BROKER=localhost MQTT_PORT=1883 -# Must match MQTT_NODE_USER/MQTT_NODE_PASS in the server's top-level .env -MQTT_USER=drb-node -MQTT_PASS=change-me-node +MQTT_TLS=false + +# Production, pointed at the public broker (real Let's Encrypt cert, default +# CA verification — do not disable it): +# MQTT_BROKER=mqtt. +# MQTT_PORT=8883 +# MQTT_TLS=true + +# DEPRECATED / REMOVED post-cutover — the shared "drb-node" login these +# backed no longer exists on the server (dynsec creates only per-node +# clients, keyed by api_key; see Server/drb-c2-core/app/internal/dynsec.py). +# Leave unset for any node pointed at a cut-over broker. Only meaningful as a +# legacy fallback if MQTT_BROKER still points at a pre-cutover broker running +# mosquitto's old password_file auth. +# MQTT_USER=drb-node +# MQTT_PASS=change-me-node # C2 server for audio upload (leave blank to disable upload) C2_URL=http://localhost:8888 @@ -79,6 +100,22 @@ TRIM_SILENCE_GUARD_SECONDS=0.25 # OP25 container (usually no need to change) OP25_API_URL=http://localhost:8001 OP25_TERMINAL_URL=http://localhost:8081 +# DEBUGGING AID, NOT A DEPLOYMENT OPTION. Both OP25's control API (:8001) and +# its HTTP terminal (:8081) have NO authentication, so they are bound to +# 127.0.0.1 by default — reachable only from other containers on this same +# host (they share its network namespace), not from the site's LAN. Setting +# this to true rebinds both to 0.0.0.0, exposing unauthenticated OP25 +# start/stop/config-rewrite and the raw terminal to anyone on that LAN. Only +# for local development off a real node; leave false everywhere else. +OP25_DEBUG_EXPOSE=false + +# --- Local dashboard / API login --------------------------------------------- +# Protects the node's local dashboard (port 80) and JSON API. The node is +# reachable by anyone on whatever site's LAN it's deployed to, so this MUST be +# changed before the node leaves the bench — the default below is flagged at +# every startup in the logs until it's changed. +DASHBOARD_USERNAME=admin +DASHBOARD_PASSWORD=CHANGE-ME-drb-default # Container registry — set these to pull pre-built images instead of building locally. # Must match the DOCKER_ORG variable and repo name configured in Gitea. diff --git a/drb-edge-node/app/config.py b/drb-edge-node/app/config.py index 1e825cb..44c853b 100644 --- a/drb-edge-node/app/config.py +++ b/drb-edge-node/app/config.py @@ -10,8 +10,27 @@ class Settings(BaseSettings): node_lon: float = 0.0 # MQTT + # + # Broker cutover (MQTT-PUBLIC-AUTH-PLAN.md, dynsec revision): the server no + # longer has a shared node login. Each node authenticates as + # username=NODE_ID, password= (the same credential + # /upload already trusts via node_keys) — see mqtt_manager._build_client(). + # For local dev against the old-style broker (localhost:1883, no TLS) set + # MQTT_BROKER=localhost and leave MQTT_TLS unset/false. mqtt_broker: str mqtt_port: int = 1883 + # Set true for the public broker (mqtt.:8883, real Let's Encrypt + # cert) so client.tls_set() runs with default system-CA verification. + # False by default so local/dev against a plaintext :1883 broker still + # works unchanged. Do NOT pair with a self-signed/insecure cert setup — + # verification is never disabled (no tls_insecure_set(True) anywhere). + mqtt_tls: bool = False + # DEPRECATED / effectively dead post-cutover: the shared node login these + # backed no longer exists on the server (dynsec has no such client — see + # dynsec.py). Left in only as a legacy fallback for a pre-cutover broker + # that still uses mosquitto's old password_file auth; mqtt_manager only + # falls back to these when no api_key is on disk yet. Do not provision new + # nodes with these — see MQTT_USER/MQTT_PASS removal note in .env.example. mqtt_user: Optional[str] = None mqtt_pass: Optional[str] = None @@ -123,6 +142,22 @@ class Settings(BaseSettings): # Offline call buffer — how many call_end events to keep while disconnected offline_call_buffer_size: int = 35 + # ------------------------------------------------------------------ + # Local dashboard / API authentication + # + # These nodes are deployed at arbitrary third-party locations, reachable by + # anyone on that site's LAN — there is no auth on this HTTP surface without + # these. The password below is a FIRST-BOOT DEFAULT ONLY: change it via + # DASHBOARD_PASSWORD in .env before a node leaves the bench. main.py logs a + # startup warning every boot the default is still active. + # + # See app/internal/auth.py — the password is never compared or stored in + # plaintext (scrypt-hashed, constant-time compare); this setting just holds + # the operator-facing plaintext the same way MQTT_PASS/ICECAST_* already do. + # ------------------------------------------------------------------ + dashboard_username: str = "admin" + dashboard_password: str = "CHANGE-ME-drb-default" + class Config: env_file = ".env" diff --git a/drb-edge-node/app/internal/auth.py b/drb-edge-node/app/internal/auth.py new file mode 100644 index 0000000..3ce95b4 --- /dev/null +++ b/drb-edge-node/app/internal/auth.py @@ -0,0 +1,178 @@ +""" +Local dashboard / API authentication. + +These edge nodes are deployed at arbitrary third-party locations and serve +both an HTML dashboard and a JSON API on the same FastAPI app (port 80, +network_mode: host) — anyone on that site's LAN can otherwise reach every +control endpoint. This module adds username/password auth in front of it. + +Design: + - Username + password come from app/config.py (DASHBOARD_USERNAME / + DASHBOARD_PASSWORD env vars), with a first-boot default that MUST be + changed — see is_using_default_password() and its call site in main.py. + - The password is never compared or stored in plaintext. It's hashed with + stdlib hashlib.scrypt (no new dependency — this image runs on a Raspberry + Pi) using a salt generated once on first boot and persisted via + app/internal/credentials.py, then compared with hmac.compare_digest. + - Two auth paths, both accepted on every protected route: + * Browser dashboard: a signed session cookie set by POST /login + (HMAC-SHA256 over "username:expiry", no server-side session store — + the signing key is the persisted session secret from credentials.py). + * Machine callers: HTTP Basic with the same username/password. As of + this writing no non-browser caller of this node's own API was found + anywhere in Client/ or Server/ (nodes are only ever reached over MQTT + + node-initiated outbound HTTP to C2, never the other way around) — + Basic is kept anyway as a stateless fallback for curl/scripts in the + field, since it needs no login flow and costs little to support. + +Caveat worth knowing: this node's dashboard is plain HTTP (no TLS +termination on :80), so both the session cookie and Basic credentials travel +unencrypted on the local network either way. Auth here stops a passerby from +opening the dashboard and pressing buttons; it does not stop a LAN-level +sniffer. That would need TLS in front of the node, which is out of scope here. +""" +import base64 +import hashlib +import hmac +import time +from typing import Optional + +from fastapi import Cookie, Header, HTTPException, status + +from app.config import settings +from app.internal import credentials +from app.internal.logger import logger + +SESSION_COOKIE_NAME = "drb_node_session" +# 12h: long enough that the dashboard doesn't demand a daily re-login on a +# device left open on someone's desk, short enough that a stolen cookie isn't +# valid forever. +SESSION_TTL_SECONDS = 12 * 60 * 60 + +# scrypt cost parameters. n=2**14 (16384) keeps the derivation well under the +# ~1s ballpark on a Raspberry Pi's memory/CPU budget — this only runs on +# login attempts (rare), never on the hot path. +_SCRYPT_N = 2 ** 14 +_SCRYPT_R = 8 +_SCRYPT_P = 1 +_SCRYPT_DKLEN = 32 + +# Kept in sync with app/config.py's Settings.dashboard_password default. +DEFAULT_PASSWORD = "CHANGE-ME-drb-default" + + +def _hash_password(password: str, salt: bytes) -> bytes: + return hashlib.scrypt( + password.encode("utf-8"), + salt=salt, + n=_SCRYPT_N, + r=_SCRYPT_R, + p=_SCRYPT_P, + dklen=_SCRYPT_DKLEN, + ) + + +def is_using_default_password() -> bool: + return settings.dashboard_password == DEFAULT_PASSWORD + + +def verify_credentials(username: str, password: str) -> bool: + """Constant-time check of a submitted username/password against config.""" + salt = credentials.get_auth_salt() + expected_hash = _hash_password(settings.dashboard_password, salt) + submitted_hash = _hash_password(password, salt) + + user_ok = hmac.compare_digest( + username.encode("utf-8"), settings.dashboard_username.encode("utf-8") + ) + pass_ok = hmac.compare_digest(submitted_hash, expected_hash) + return user_ok and pass_ok + + +def _sign(payload: str) -> str: + secret = credentials.get_session_secret() + return hmac.new(secret, payload.encode("utf-8"), hashlib.sha256).hexdigest() + + +def create_session_token(username: str) -> str: + """Build a signed, expiring, opaque session token (no server-side state).""" + expiry = int(time.time()) + SESSION_TTL_SECONDS + payload = f"{username}:{expiry}" + sig = _sign(payload) + raw = f"{payload}:{sig}" + return base64.urlsafe_b64encode(raw.encode("utf-8")).decode("utf-8") + + +def _verify_session_token(token: str) -> Optional[str]: + try: + raw = base64.urlsafe_b64decode(token.encode("utf-8")).decode("utf-8") + username, expiry_s, sig = raw.rsplit(":", 2) + expiry = int(expiry_s) + except Exception: + return None + + expected_sig = _sign(f"{username}:{expiry_s}") + if not hmac.compare_digest(sig, expected_sig): + return None + if time.time() > expiry: + return None + if not hmac.compare_digest( + username.encode("utf-8"), settings.dashboard_username.encode("utf-8") + ): + return None + return username + + +def _verify_basic_auth(header_value: str) -> bool: + try: + scheme, _, encoded = header_value.partition(" ") + if scheme.lower() != "basic": + return False + decoded = base64.b64decode(encoded).decode("utf-8") + username, _, password = decoded.partition(":") + except Exception: + return False + return verify_credentials(username, password) + + +def is_authenticated( + session_cookie: Optional[str], authorization: Optional[str] +) -> bool: + if session_cookie and _verify_session_token(session_cookie): + return True + if authorization and _verify_basic_auth(authorization): + return True + return False + + +async def require_session( + drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME), +) -> bool: + """Dependency for dashboard HTML pages. Returns False rather than raising + so the route can redirect to /login instead of showing a bare 401.""" + return bool(drb_node_session and _verify_session_token(drb_node_session)) + + +async def require_auth( + drb_node_session: Optional[str] = Cookie(default=None, alias=SESSION_COOKIE_NAME), + authorization: Optional[str] = Header(default=None), +) -> None: + """Dependency for /api/* routes — session cookie (dashboard's own fetch + calls) or HTTP Basic (machine callers) both satisfy it.""" + if is_authenticated(drb_node_session, authorization): + return + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required", + headers={"WWW-Authenticate": "Basic"}, + ) + + +def warn_if_default_password() -> None: + if is_using_default_password(): + logger.warning( + "DASHBOARD_PASSWORD is still the first-boot default — " + "set DASHBOARD_USERNAME/DASHBOARD_PASSWORD in .env before this " + "node leaves the bench. Anyone on the node's LAN can currently " + "log in with the default credentials." + ) diff --git a/drb-edge-node/app/internal/credentials.py b/drb-edge-node/app/internal/credentials.py index e93b374..d56213e 100644 --- a/drb-edge-node/app/internal/credentials.py +++ b/drb-edge-node/app/internal/credentials.py @@ -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)) diff --git a/drb-edge-node/app/internal/mqtt_manager.py b/drb-edge-node/app/internal/mqtt_manager.py index 294bd4a..8b0f1c0 100644 --- a/drb-edge-node/app/internal/mqtt_manager.py +++ b/drb-edge-node/app/internal/mqtt_manager.py @@ -32,6 +32,16 @@ class MQTTManager: self._t_metadata = f"nodes/{nid}/metadata" self._t_commands = f"nodes/{nid}/commands" self._t_config = f"nodes/{nid}/config" + # TODO(mqtt-cutover): dead once enrollment lands client-side. This + # was the pre-dynsec key-delivery path (server retain-publishes the + # api_key here after admin approval; node asks for redelivery via + # _t_key_request if none shows up). Under dynsec a node with no + # api_key can't authenticate to the broker at all — see + # _build_client() — so this subscribe is only ever reachable while + # still using the legacy mqtt_user/mqtt_pass fallback against a + # pre-cutover broker. Left in as the rollback path per + # MQTT-PUBLIC-AUTH-PLAN.md; remove together with the server's + # matching TODO(mqtt-cutover) markers once enrollment replaces it. self._t_api_key = f"nodes/{nid}/api_key" self._t_key_request = f"nodes/{nid}/key_request" self._t_discovery = "nodes/discovery/request" @@ -41,8 +51,47 @@ class MQTTManager: callback_api_version=mqtt.CallbackAPIVersion.VERSION2, client_id=settings.node_id, ) - if settings.mqtt_user: + + api_key = credentials.get_api_key() + if api_key: + # Post-cutover auth: broker's dynsec plugin authenticates this + # exact (username, password) pair as this node's own client — see + # Server/drb-c2-core/app/internal/dynsec.py upsert_node_client() + # and MQTT-PUBLIC-AUTH-PLAN.md. node_id doubles as the dynsec + # username AND the %u substitution in the "node" role's + # nodes/%u/# ACL pattern, so it must match exactly what C2 has on + # file for this node (it always does — node_id is not operator + # editable post-provisioning). + client.username_pw_set(settings.node_id, api_key) + elif settings.mqtt_user: + # Legacy fallback — only valid against a pre-cutover broker still + # using mosquitto's old password_file auth. See config.py's + # mqtt_user/mqtt_pass docstring. Not accepted by a dynsec broker. client.username_pw_set(settings.mqtt_user, settings.mqtt_pass) + else: + # No api_key on disk and no legacy shared login configured. A + # dynsec broker (allow_anonymous false) refuses this outright — + # expected, not a bug to route around here: this node hasn't been + # enrolled/approved yet, and the enrollment flow that would fix + # that client-side is a later, separate pass (out of scope here; + # see MQTT-PUBLIC-AUTH-PLAN.md). paho's reconnect_delay_set() + # below bounds the retry rate (2..60s exponential backoff), so + # this degrades to a slow, clearly-logged refusal loop via + # _on_connect's "MQTT connect refused" line — not a hot spin. + logger.warning( + "No API key on disk and no legacy MQTT_USER configured — " + "connecting without credentials; the broker is expected to " + "refuse this until the node is enrolled/approved." + ) + + if settings.mqtt_tls: + # No arguments = system CA store + ssl.CERT_REQUIRED (verified + # against paho's tls_set() source/docstring — unverified by + # running anything, per instruction). The broker presents a real + # Let's Encrypt cert for mqtt.:8883, so default + # verification is exactly correct: do not pass ca_certs, do not + # call tls_insecure_set(True). + client.tls_set() lwt = json.dumps({ "node_id": settings.node_id, @@ -62,10 +111,11 @@ class MQTTManager: self._connected = True client.subscribe(self._t_commands, qos=1) client.subscribe(self._t_config, qos=1) - client.subscribe(self._t_api_key, qos=2) + client.subscribe(self._t_api_key, qos=2) # TODO(mqtt-cutover): see _t_api_key comment above client.subscribe(self._t_discovery, qos=0) logger.info("MQTT connected.") asyncio.run_coroutine_threadsafe(self._publish_checkin(), self._loop) + # TODO(mqtt-cutover): see _t_api_key comment above asyncio.run_coroutine_threadsafe(self._maybe_request_key(), self._loop) asyncio.run_coroutine_threadsafe(self._flush_offline_buffer(), self._loop) else: diff --git a/drb-edge-node/app/main.py b/drb-edge-node/app/main.py index 1e4df34..0020dd7 100644 --- a/drb-edge-node/app/main.py +++ b/drb-edge-node/app/main.py @@ -266,8 +266,11 @@ async def on_config_push(payload: dict): async def lifespan(app: FastAPI): logger.info(f"Edge node starting — ID: {settings.node_id}") - # Load persisted credentials (API key provisioned by C2 after approval) + # Load persisted credentials (API key provisioned by C2 after approval; + # also generates/loads the local dashboard's auth salt + session secret) credentials.load() + from app.internal import auth + auth.warn_if_default_password() # Wire callbacks metadata_watcher.on_call_start = on_call_start diff --git a/drb-edge-node/app/routers/api.py b/drb-edge-node/app/routers/api.py index 29d84b0..74ecc13 100644 --- a/drb-edge-node/app/routers/api.py +++ b/drb-edge-node/app/routers/api.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, HTTPException, Body +from fastapi import APIRouter, Depends, HTTPException, Body from typing import Optional import asyncio import httpx @@ -11,8 +11,14 @@ from app.internal.discord_radio import radio_bot from app.internal.metadata_watcher import metadata_watcher from app.internal import credentials from app.internal.mqtt_manager import mqtt_manager +from app.internal import auth -router = APIRouter(prefix="/api", tags=["api"]) +# Every route in this router requires auth — a valid dashboard session cookie +# or HTTP Basic (see app/internal/auth.py). No exemption exists for any route +# here: there is no health/liveness endpoint in this file or anywhere else in +# the edge node (confirmed against source — no docker healthcheck references +# one either), so nothing needs to stay open for a container healthcheck. +router = APIRouter(prefix="/api", tags=["api"], dependencies=[Depends(auth.require_auth)]) @router.get("/status") diff --git a/drb-edge-node/app/routers/ui.py b/drb-edge-node/app/routers/ui.py index 0763aa0..b7df9f9 100644 --- a/drb-edge-node/app/routers/ui.py +++ b/drb-edge-node/app/routers/ui.py @@ -1,18 +1,64 @@ from pathlib import Path -from fastapi import APIRouter -from fastapi.responses import HTMLResponse +from typing import Optional + +from fastapi import APIRouter, Depends, Form +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.internal import auth router = APIRouter(tags=["ui"]) _TEMPLATE = Path(__file__).parent.parent / "templates" / "index.html" _SCANNER_TEMPLATE = Path(__file__).parent.parent / "templates" / "scanner.html" +_LOGIN_TEMPLATE = Path(__file__).parent.parent / "templates" / "login.html" + + +@router.get("/login", response_class=HTMLResponse) +async def login_page(error: Optional[str] = None): + html = _LOGIN_TEMPLATE.read_text() + banner = ( + '
Invalid username or password.
' if error else "" + ) + return html.replace("", banner) + + +@router.post("/login") +async def login_submit(username: str = Form(...), password: str = Form(...)): + if not auth.verify_credentials(username, password): + return RedirectResponse("/login?error=1", status_code=303) + + token = auth.create_session_token(username) + resp = RedirectResponse("/", status_code=303) + resp.set_cookie( + auth.SESSION_COOKIE_NAME, + token, + max_age=auth.SESSION_TTL_SECONDS, + httponly=True, + samesite="lax", + # No TLS termination on this port (LAN dashboard on :80) — `secure` + # would make the cookie never get sent at all. + secure=False, + ) + return resp + + +@router.post("/logout") +@router.get("/logout") +async def logout(): + resp = RedirectResponse("/login", status_code=303) + resp.delete_cookie(auth.SESSION_COOKIE_NAME) + return resp @router.get("/", response_class=HTMLResponse) -async def index(): +async def index(authed: bool = Depends(auth.require_session)): + if not authed: + return RedirectResponse("/login") return _TEMPLATE.read_text() @router.get("/scanner", response_class=HTMLResponse) -async def scanner(): +async def scanner(authed: bool = Depends(auth.require_session)): + if not authed: + return RedirectResponse("/login") return _SCANNER_TEMPLATE.read_text() diff --git a/drb-edge-node/app/templates/index.html b/drb-edge-node/app/templates/index.html index 5ba6174..98c1795 100644 --- a/drb-edge-node/app/templates/index.html +++ b/drb-edge-node/app/templates/index.html @@ -268,6 +268,10 @@ Scanner Mode + + + Logout + diff --git a/drb-edge-node/app/templates/login.html b/drb-edge-node/app/templates/login.html new file mode 100644 index 0000000..2358fcf --- /dev/null +++ b/drb-edge-node/app/templates/login.html @@ -0,0 +1,133 @@ + + + + + + DRB Edge Node — Login + + + + + + + diff --git a/drb-edge-node/requirements.txt b/drb-edge-node/requirements.txt index a82a06d..52ed9a1 100644 --- a/drb-edge-node/requirements.txt +++ b/drb-edge-node/requirements.txt @@ -5,5 +5,6 @@ paho-mqtt>=2.0.0 httpx discord.py[voice] PyNaCl +python-multipart pytest pytest-asyncio diff --git a/drb-edge-node/tests/test_auth.py b/drb-edge-node/tests/test_auth.py new file mode 100644 index 0000000..7aab0b6 --- /dev/null +++ b/drb-edge-node/tests/test_auth.py @@ -0,0 +1,248 @@ +""" +Unit tests for local dashboard/API auth (app.internal.auth), plus the +credentials.py additions that persist its signing material (auth_salt, +session_secret) alongside the existing node API key. + +This file is pure logic: password hashing/constant-time comparison, session +token signing/expiry, and HTTP Basic header parsing. See test_auth_endpoints.py +for the HTTP-level login/redirect/protection round trip through the routers. +""" +import base64 +import secrets +import time +from unittest.mock import patch + +import pytest +from fastapi import HTTPException + +from app.config import settings +from app.internal import auth, credentials + + +@pytest.fixture(autouse=True) +def isolated_credentials(tmp_path, monkeypatch): + """Every test gets a fresh, on-disk-isolated credentials store so the + generated auth salt / session secret never leak between tests, and a + known username/password instead of the shipped default.""" + creds_file = tmp_path / "credentials.json" + monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file) + monkeypatch.setattr(credentials, "_api_key", None) + monkeypatch.setattr(credentials, "_auth_salt", None) + monkeypatch.setattr(credentials, "_session_secret", None) + monkeypatch.setattr(settings, "dashboard_username", "tester") + monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass") + yield + + +def _basic_header(username: str, password: str) -> str: + encoded = base64.b64encode(f"{username}:{password}".encode()).decode() + return f"Basic {encoded}" + + +# --------------------------------------------------------------------------- +# credentials.py: auth salt / session secret generation + persistence +# --------------------------------------------------------------------------- + +def test_auth_material_is_generated_on_first_access(): + salt = credentials.get_auth_salt() + secret = credentials.get_session_secret() + assert isinstance(salt, bytes) and len(salt) == 16 + assert isinstance(secret, bytes) and len(secret) == 32 + + +def test_auth_material_is_stable_across_repeated_calls(): + assert credentials.get_auth_salt() == credentials.get_auth_salt() + assert credentials.get_session_secret() == credentials.get_session_secret() + + +def test_auth_material_persists_to_disk_and_survives_reload(): + salt = credentials.get_auth_salt() + secret = credentials.get_session_secret() + + # Simulate a container restart: drop in-memory state, reload from disk. + credentials._api_key = None + credentials._auth_salt = None + credentials._session_secret = None + credentials.load() + + assert credentials.get_auth_salt() == salt + assert credentials.get_session_secret() == secret + + +def test_saving_api_key_does_not_clobber_auth_material(): + """save_api_key() used to json.dumps({"api_key": key}) directly, which + would have wiped auth_salt/session_secret out of credentials.json the + moment C2 provisioned an API key after this feature was added.""" + salt = credentials.get_auth_salt() + secret = credentials.get_session_secret() + + credentials.save_api_key("some-node-api-key") + + assert credentials.get_api_key() == "some-node-api-key" + assert credentials.get_auth_salt() == salt + assert credentials.get_session_secret() == secret + + +# --------------------------------------------------------------------------- +# verify_credentials() — password hashing + constant-time compare +# --------------------------------------------------------------------------- + +def test_verify_credentials_accepts_correct_username_and_password(): + assert auth.verify_credentials("tester", "s3cret-pass") is True + + +def test_verify_credentials_rejects_wrong_password(): + assert auth.verify_credentials("tester", "wrong") is False + + +def test_verify_credentials_rejects_wrong_username(): + assert auth.verify_credentials("someone-else", "s3cret-pass") is False + + +def test_verify_credentials_rejects_empty_password(): + assert auth.verify_credentials("tester", "") is False + + +def test_password_is_hashed_not_compared_in_plaintext(): + with patch.object(auth, "_hash_password", wraps=auth._hash_password) as spy: + auth.verify_credentials("tester", "s3cret-pass") + # Once for the configured password, once for the submitted one — neither + # side is ever compared as a raw string. + assert spy.call_count == 2 + + +def test_is_using_default_password_detects_the_shipped_default(monkeypatch): + monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD) + assert auth.is_using_default_password() is True + + +def test_is_using_default_password_false_once_changed(): + assert auth.is_using_default_password() is False # fixture already changed it + + +# --------------------------------------------------------------------------- +# session tokens +# --------------------------------------------------------------------------- + +def test_session_token_round_trips(): + token = auth.create_session_token("tester") + assert auth._verify_session_token(token) == "tester" + + +def test_session_token_rejects_tampered_payload(): + token = auth.create_session_token("tester") + tampered = ("X" if token[0] != "X" else "Y") + token[1:] + assert auth._verify_session_token(tampered) is None + + +def test_session_token_rejects_expired_token(monkeypatch): + token = auth.create_session_token("tester") + future = time.time() + auth.SESSION_TTL_SECONDS + 1 + monkeypatch.setattr(time, "time", lambda: future) + assert auth._verify_session_token(token) is None + + +def test_session_token_rejects_username_mismatch(monkeypatch): + token = auth.create_session_token("tester") + monkeypatch.setattr(settings, "dashboard_username", "someone-else") + assert auth._verify_session_token(token) is None + + +def test_session_token_garbage_input_does_not_raise(): + assert auth._verify_session_token("not-a-real-token") is None + assert auth._verify_session_token("") is None + + +def test_session_token_signed_with_a_different_secret_is_rejected(): + token = auth.create_session_token("tester") + # As if the node restarted without a persisted credentials.json. + credentials._session_secret = secrets.token_bytes(32) + assert auth._verify_session_token(token) is None + + +# --------------------------------------------------------------------------- +# HTTP Basic parsing +# --------------------------------------------------------------------------- + +def test_basic_auth_accepts_valid_header(): + assert auth._verify_basic_auth(_basic_header("tester", "s3cret-pass")) is True + + +def test_basic_auth_rejects_wrong_credentials(): + assert auth._verify_basic_auth(_basic_header("tester", "wrong")) is False + + +def test_basic_auth_rejects_non_basic_scheme(): + assert auth._verify_basic_auth("Bearer sometoken") is False + + +def test_basic_auth_tolerates_garbage_without_raising(): + assert auth._verify_basic_auth("Basic not-valid-base64!!") is False + assert auth._verify_basic_auth("") is False + + +# --------------------------------------------------------------------------- +# is_authenticated() — the combined check require_auth is built on +# --------------------------------------------------------------------------- + +def test_is_authenticated_true_with_valid_session_cookie(): + token = auth.create_session_token("tester") + assert auth.is_authenticated(token, None) is True + + +def test_is_authenticated_true_with_valid_basic_header(): + assert auth.is_authenticated(None, _basic_header("tester", "s3cret-pass")) is True + + +def test_is_authenticated_false_with_neither(): + assert auth.is_authenticated(None, None) is False + + +def test_is_authenticated_false_with_invalid_session_and_no_header(): + assert auth.is_authenticated("garbage", None) is False + + +# --------------------------------------------------------------------------- +# FastAPI dependencies: require_session / require_auth +# --------------------------------------------------------------------------- + +async def test_require_session_false_with_no_cookie(): + assert await auth.require_session(None) is False + + +async def test_require_session_true_with_valid_cookie(): + token = auth.create_session_token("tester") + assert await auth.require_session(token) is True + + +async def test_require_auth_raises_401_with_no_credentials(): + with pytest.raises(HTTPException) as exc_info: + await auth.require_auth(None, None) + assert exc_info.value.status_code == 401 + assert exc_info.value.headers["WWW-Authenticate"] == "Basic" + + +async def test_require_auth_passes_with_valid_session_cookie(): + token = auth.create_session_token("tester") + await auth.require_auth(token, None) # must not raise + + +async def test_require_auth_passes_with_valid_basic_header(): + await auth.require_auth(None, _basic_header("tester", "s3cret-pass")) # must not raise + + +# --------------------------------------------------------------------------- +# startup warning +# --------------------------------------------------------------------------- + +def test_warn_if_default_password_logs_when_default(monkeypatch): + monkeypatch.setattr(settings, "dashboard_password", auth.DEFAULT_PASSWORD) + with patch("app.internal.auth.logger") as mock_logger: + auth.warn_if_default_password() + mock_logger.warning.assert_called_once() + + +def test_warn_if_default_password_silent_once_changed(): + with patch("app.internal.auth.logger") as mock_logger: + auth.warn_if_default_password() + mock_logger.warning.assert_not_called() diff --git a/drb-edge-node/tests/test_auth_endpoints.py b/drb-edge-node/tests/test_auth_endpoints.py new file mode 100644 index 0000000..eda904f --- /dev/null +++ b/drb-edge-node/tests/test_auth_endpoints.py @@ -0,0 +1,157 @@ +""" +HTTP-level tests for the auth-protected dashboard/API surface: login/logout +flow, session-cookie protection of the HTML pages, and Basic-auth protection +of the JSON API. + +Built as a standalone FastAPI app assembling the real api/ui routers — NOT +app.main:app, which wires a lifespan that connects to MQTT, starts the +PulseAudio capture loop, and pings OP25/C2. None of that belongs in a unit +test, and none of it is needed to exercise the auth layer: the auth +dependency runs (and short-circuits with a redirect/401) before any route +body that would touch those services. +""" +import base64 + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from app.config import settings +from app.internal import auth, credentials +from app.routers import api, ui + + +@pytest.fixture(autouse=True) +def isolated_credentials(tmp_path, monkeypatch): + creds_file = tmp_path / "credentials.json" + monkeypatch.setattr(credentials, "_CREDS_FILE", creds_file) + monkeypatch.setattr(credentials, "_api_key", None) + monkeypatch.setattr(credentials, "_auth_salt", None) + monkeypatch.setattr(credentials, "_session_secret", None) + monkeypatch.setattr(settings, "dashboard_username", "tester") + monkeypatch.setattr(settings, "dashboard_password", "s3cret-pass") + yield + + +@pytest.fixture +def client(): + test_app = FastAPI() + test_app.include_router(api.router) + test_app.include_router(ui.router) + with TestClient(test_app) as c: + yield c + + +def _basic_header(username: str, password: str) -> dict: + encoded = base64.b64encode(f"{username}:{password}".encode()).decode() + return {"Authorization": f"Basic {encoded}"} + + +# --------------------------------------------------------------------------- +# /api/* — machine-facing JSON API +# --------------------------------------------------------------------------- + +def test_api_route_rejects_unauthenticated_requests(client): + r = client.get("/api/status", follow_redirects=False) + assert r.status_code == 401 + assert r.headers["www-authenticate"] == "Basic" + + +def test_api_route_accepts_valid_basic_auth(client): + r = client.get("/api/config", headers=_basic_header("tester", "s3cret-pass")) + assert r.status_code == 200 + + +def test_api_route_rejects_wrong_basic_auth_password(client): + r = client.get("/api/config", headers=_basic_header("tester", "wrong")) + assert r.status_code == 401 + + +def test_api_route_accepts_dashboard_session_cookie(client): + login = client.post( + "/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False + ) + assert login.status_code == 303 + assert auth.SESSION_COOKIE_NAME in login.cookies + + r = client.get("/api/config") # cookie jar carries the session cookie + assert r.status_code == 200 + + +def test_every_api_route_is_registered_behind_the_auth_dependency(): + """Structural guard: catches a future route added to api.py that forgets + the router is meant to protect everything in it.""" + assert any( + getattr(dep, "dependency", None) is auth.require_auth + for dep in api.router.dependencies + ) + + +# --------------------------------------------------------------------------- +# / and /scanner — the HTML dashboard +# --------------------------------------------------------------------------- + +def test_index_redirects_to_login_when_unauthenticated(client): + r = client.get("/", follow_redirects=False) + assert r.status_code in (302, 307) + assert r.headers["location"] == "/login" + + +def test_scanner_redirects_to_login_when_unauthenticated(client): + r = client.get("/scanner", follow_redirects=False) + assert r.status_code in (302, 307) + assert r.headers["location"] == "/login" + + +def test_index_served_with_a_valid_session_cookie(client): + client.post("/login", data={"username": "tester", "password": "s3cret-pass"}) + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + + +# --------------------------------------------------------------------------- +# /login, /logout +# --------------------------------------------------------------------------- + +def test_login_page_loads_without_auth(client): + r = client.get("/login") + assert r.status_code == 200 + + +def test_login_with_correct_credentials_sets_cookie_and_redirects_home(client): + r = client.post( + "/login", data={"username": "tester", "password": "s3cret-pass"}, follow_redirects=False + ) + assert r.status_code == 303 + assert r.headers["location"] == "/" + cookie = r.cookies.get(auth.SESSION_COOKIE_NAME) + assert cookie + assert auth._verify_session_token(cookie) == "tester" + + +def test_login_with_wrong_password_redirects_back_with_error_and_no_cookie(client): + r = client.post( + "/login", data={"username": "tester", "password": "wrong"}, follow_redirects=False + ) + assert r.status_code == 303 + assert r.headers["location"] == "/login?error=1" + assert auth.SESSION_COOKIE_NAME not in r.cookies + + +def test_login_error_banner_renders_on_the_login_page(client): + r = client.get("/login?error=1") + assert r.status_code == 200 + assert "Invalid username or password" in r.text + + +def test_logout_clears_the_session_cookie_and_redirects_to_login(client): + client.post("/login", data={"username": "tester", "password": "s3cret-pass"}) + assert client.get("/").status_code == 200 # confirm we were logged in + + r = client.get("/logout", follow_redirects=False) + assert r.status_code == 303 + assert r.headers["location"] == "/login" + + r2 = client.get("/", follow_redirects=False) + assert r2.status_code in (302, 307) # session cookie was cleared diff --git a/drb-edge-node/tests/test_mqtt_manager.py b/drb-edge-node/tests/test_mqtt_manager.py new file mode 100644 index 0000000..cfcb60f --- /dev/null +++ b/drb-edge-node/tests/test_mqtt_manager.py @@ -0,0 +1,142 @@ +""" +Unit tests for mqtt_manager's per-node auth + TLS wiring +(MQTT-PUBLIC-AUTH-PLAN.md dynsec cutover). + +Pure client-construction tests — _build_client() only builds a paho Client +object, it never calls .connect(), so no real broker is involved. What's +verified here is the credential/TLS *selection logic*, matching what the +server's dynsec plugin now expects (username=node_id, password=api_key, +default-verified TLS on the public listener) — see +Server/drb-c2-core/app/internal/dynsec.py and mosquitto.conf (read-only +reference, not touched by this change). +""" +import ssl +from unittest.mock import patch + +import pytest + +from app.config import settings +from app.internal import credentials +from app.internal.mqtt_manager import mqtt_manager + + +@pytest.fixture(autouse=True) +def isolated_mqtt_settings(monkeypatch): + """Every test gets known, isolated mqtt_* settings and a clean + credentials._api_key so tests can't see real .env values or leak state + between tests (mirrors the isolated_credentials fixture in test_auth.py).""" + monkeypatch.setattr(settings, "mqtt_user", None) + monkeypatch.setattr(settings, "mqtt_pass", None) + monkeypatch.setattr(settings, "mqtt_tls", False) + monkeypatch.setattr(credentials, "_api_key", None) + yield + + +# --------------------------------------------------------------------------- +# Credential selection: api_key > legacy mqtt_user > anonymous +# --------------------------------------------------------------------------- + +def test_build_client_uses_node_id_and_api_key_when_present(monkeypatch): + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + + client = mqtt_manager._build_client() + + assert client._username == settings.node_id.encode() + assert client._password == b"the-api-key" + + +def test_build_client_falls_back_to_legacy_mqtt_user_without_api_key(monkeypatch): + monkeypatch.setattr(settings, "mqtt_user", "drb-node") + monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass") + + client = mqtt_manager._build_client() + + assert client._username == b"drb-node" + assert client._password == b"legacy-pass" + + +def test_build_client_api_key_takes_priority_over_legacy_mqtt_user(monkeypatch): + """Once a node has a real api_key, it must never fall back to the shared + legacy login even if MQTT_USER/MQTT_PASS are still set in .env.""" + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + monkeypatch.setattr(settings, "mqtt_user", "drb-node") + monkeypatch.setattr(settings, "mqtt_pass", "legacy-pass") + + client = mqtt_manager._build_client() + + assert client._username == settings.node_id.encode() + assert client._password == b"the-api-key" + + +def test_build_client_with_no_credentials_connects_anonymously(monkeypatch): + """No api_key on disk, no legacy login configured: _build_client() must + still return a usable client (paho, not this code, decides what happens + on the wire — the dynsec broker refuses it, see the warning test below). + This must never raise.""" + client = mqtt_manager._build_client() + + assert client._username is None + assert client._password is None + + +def test_build_client_warns_when_no_credentials_available(caplog): + with caplog.at_level("WARNING", logger="drb-edge-node"): + mqtt_manager._build_client() + + messages = [r.message for r in caplog.records] + assert any("No API key" in m for m in messages), \ + "an unenrolled node must log a clear, greppable warning, not fail silently" + + +def test_build_client_does_not_warn_when_api_key_present(monkeypatch, caplog): + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + + with caplog.at_level("WARNING", logger="drb-edge-node"): + mqtt_manager._build_client() + + assert not any("No API key" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# TLS +# --------------------------------------------------------------------------- + +def test_build_client_no_tls_by_default(monkeypatch): + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + monkeypatch.setattr(settings, "mqtt_tls", False) + + client = mqtt_manager._build_client() + + assert client._ssl_context is None + + +def test_build_client_enables_tls_with_default_verification(monkeypatch): + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + monkeypatch.setattr(settings, "mqtt_tls", True) + + client = mqtt_manager._build_client() + + assert isinstance(client._ssl_context, ssl.SSLContext) + # The whole point: default CA verification against the broker's real + # Let's Encrypt cert must stay ON. tls_insecure_set(True) must never be + # called — that would defeat verification entirely. + assert client._ssl_context.verify_mode == ssl.CERT_REQUIRED + assert client._tls_insecure is False + + +# --------------------------------------------------------------------------- +# Offline call buffer must be untouched by the auth/TLS change +# --------------------------------------------------------------------------- + +def test_build_client_does_not_touch_offline_buffer(monkeypatch): + """_build_client() is called fresh on every connect(); it must never + reset or otherwise touch the offline call-buffer deque — that survives + reconnects/auth changes by design (the whole point of the buffer).""" + monkeypatch.setattr(credentials, "_api_key", "the-api-key") + mqtt_manager._offline_buffer.append(("nodes/test/metadata", {"call_id": "sentinel"})) + + with patch.object(mqtt_manager, "_offline_buffer", mqtt_manager._offline_buffer): + mqtt_manager._build_client() + + assert list(mqtt_manager._offline_buffer) == [("nodes/test/metadata", {"call_id": "sentinel"})] + mqtt_manager._offline_buffer.clear()