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>
179 lines
6.7 KiB
Python
179 lines
6.7 KiB
Python
"""
|
|
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."
|
|
)
|