Authenticate the node dashboard, and the broker connection per node
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:
co-authored by
Claude Opus 5
parent
a61a7b2c31
commit
87633ab50d
@@ -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."
|
||||
)
|
||||
@@ -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))
|
||||
|
||||
@@ -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.<domain>: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:
|
||||
|
||||
Reference in New Issue
Block a user