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."
|
||||
)
|
||||
Reference in New Issue
Block a user