Device security and robustness (openpanel C0): check code, pnc, lenient report

- POST /devices/pair answers a 2-character `check`, listed by /devices/pending
  and shown beside each waiting entry; five codes a minute per address, and at
  50 waiting the oldest gives way instead of a 429
- portal-minted device tokens are asked for with the device's nonce (`pnc`) and
  held against it, so unpair and delete revoke them; one without `pnc` stands
  only while the nonce is 0
- a device credential acts as the device (`device_principal`), not as the
  approving superuser, so deleting that account unpairs nothing
- the report is validated port by port: unknown enum values read as `unknown`,
  unreadable ports and sections are left out and logged, `config` may name an
  entry of `config_schemas`; 422 only for no schema 1 or no object
- devices.json is parsed once per change (cached by stat), delete drops the
  report and keeps the nonce, a busy recheck runs in the background
- scope and io only from enabled flows (start/stop tells the devices), a port
  bound twice gives one io row
- the Devices tab's saves cancel and hold back device refetches until settled
This commit is contained in:
Melvin Strobl
2026-10-16 19:26:05 +02:00
parent 13d23f6baf
commit c604cfdf3b
21 changed files with 773 additions and 119 deletions
+44 -15
View File
@@ -53,9 +53,10 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
effect at once. Or the device's nonce moved on, which revokes exactly one
screen's and leaves the device and its dashboards alone.
The nonce is only held against a credential this instance signed. One the
portal minted for a remote screen carries none, the portal names the
device and nothing else, and is revoked at the hub instead.
The nonce is held against a credential the portal minted for a remote
screen as well, which carries the nonce this instance named when it asked
for it. One from an older portal carries none and reads as 0, so it
stands only until the device is first unpaired or deleted.
The claim is still called ``panel``: tokens in circulation say it, and
nothing pairs again for a rename.
@@ -71,13 +72,12 @@ def _device_for(payload: dict[str, Any], app: Any) -> DeviceDef:
device = store.find(str(payload.get("panel", "")))
if device is None:
raise InvalidTokenError("This device no longer exists")
if payload.get("aud") == security.PANEL_AUDIENCE:
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as exc:
raise DeviceUnavailable(f"The device nonces are unreadable: {exc}")
if int(payload.get("pnc") or 0) != nonce:
raise InvalidTokenError("This device was paired again")
try:
nonce = store.nonce(device.id)
except (OSError, ValueError) as exc:
raise DeviceUnavailable(f"The device nonces are unreadable: {exc}")
if int(payload.get("pnc") or 0) != nonce:
raise InvalidTokenError("This device was paired again")
presence.seen(device.id)
return device
@@ -189,8 +189,9 @@ def _device_digests(device: DeviceDef, request: Request) -> set[str]:
def _device_may(payload: dict[str, Any], request: Request) -> None:
"""Refuse anything a wall panel has no business asking for.
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
A panel credential acts as the device itself, an account of no rights
(``device_principal``), and what that account may reach is decided here
and nowhere else. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition and report, the messages its own widgets and
ports bind to, and the bytes those messages currently point at, for a tile
@@ -214,9 +215,9 @@ def _device_may(payload: dict[str, Any], request: Request) -> None:
The device is left on ``request.state`` for the route, which credits a
value published on one of its ports to the node holding that port.
# ponytail: the device file, its nonce and the published dashboards are
# re-read per request. Cache them behind the store's revision if this
# shows up in a profile.
# ponytail: the device file and the published dashboards are re-read per
# request (the nonces are cached by the file's stat). Cache them behind
# the store's revision if this shows up in a profile.
"""
device = _device_for(payload, request.app)
request.state.device = device
@@ -402,9 +403,35 @@ def _gate_panel(
return payload
#: Where the ids of device principals come from, so each device has its own.
_DEVICE_IDS = uuid.UUID("5e1f2a0c-6d3b-4c1e-9f0a-0d3c1e5b7a20")
def device_principal(device_id: str) -> User:
"""The account a device credential acts as: the device, and no person.
Not stored. A device's credential is checked against the device itself
(``_device_for``) and bounded by route (``_device_may``), so no account's
rights are in play, and deleting or deactivating the superuser who
approved the pairing leaves the device paired. Never a superuser, so a
route gated on one refuses it even past the scope check.
"""
return User(
id=uuid.uuid5(_DEVICE_IDS, device_id),
email=f"{device_id}@device.invalid",
hashed_password="",
full_name=device_id,
is_active=True,
is_superuser=False,
)
def _user_for(session: Session, token_data: TokenPayload) -> User | None:
"""The local account a payload names, by id or by portal identity.
A device credential, already checked by ``decode_token``, names the
device instead, which acts as itself.
A token the portal minted names a person on the portal, not a user here, so
a mapping is what turns one into the other. There are two ways one comes to
exist: a superuser typed their code in, which makes the account up front,
@@ -418,6 +445,8 @@ def _user_for(session: Session, token_data: TokenPayload) -> User | None:
the account it named, and a person dropped at the portal is not adopted
again.
"""
if token_data.panel:
return device_principal(token_data.panel)
if token_data.portal_sub:
user = session.exec(
select(User).where(User.portal_sub == token_data.portal_sub)
+85 -26
View File
@@ -6,6 +6,10 @@ device's page to say which device it is, or picks the request from the list of
devices waiting on this network. The device polls, collects the credential the
approval minted, and never asks again.
A request from the list carries a two-character check that the device shows
beside its code, so whoever picks it can tell it is the screen in front of
them: behind some proxies every address looks private.
A screen hanging somewhere this instance is not reachable from does the
same thing through the portal, which forwards the two pairing calls down the
tunnel without a session and mints the credential itself when the approval
@@ -16,7 +20,8 @@ The credential is scoped: ``fluksio.api.deps`` lets it reach the dashboards
that device was assigned, the messages their widgets bind to, the messages the
device nodes in published flows wire its own ports to, and nothing else.
Deleting the device revokes it, and so does unpairing, which bumps the
device's nonce and leaves what it shows as it was.
device's nonce and leaves what it shows as it was. That holds for a credential
the portal minted too, which carries the nonce the device had when it paired.
Once paired, a device says what it has with ``PUT /devices/{id}/report``: its
board, its screen and the ports it serves, which the device node's ports are
@@ -29,6 +34,8 @@ device has updated.
from __future__ import annotations
import asyncio
import logging
import secrets
import time
from contextlib import suppress
@@ -47,6 +54,7 @@ from fluksio.api.deps import (
get_current_active_superuser,
get_current_user,
)
from fluksio.api.routes.oauth import too_many
from fluksio.cloud import config as cloud_config
from fluksio.core import security
from fluksio.core.config import settings
@@ -57,16 +65,18 @@ from fluksio.flow.devices import (
DeviceExists,
DeviceIo,
DeviceNotFound,
DeviceReport,
DeviceStore,
is_lan,
presence,
read_report,
)
from fluksio.flow.events import event_bus
from fluksio.flow.state import CONNECT_TIMEOUT_S, SOCKET_TIMEOUT_S
from fluksio.flow.store import StaleVersion
from fluksio.models import Message, User
logger = logging.getLogger(__name__)
#: Gated per route rather than on the router: the pairing endpoints are the
#: only unauthenticated ones in the app, because a device with no credential is
#: the whole point of them.
@@ -84,10 +94,18 @@ PAIR_TTL = 600
#: No I/O/0/1: the code is read off a wall and typed on a keyboard.
CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
CODE_LENGTH = 6
#: The check a device shows beside its code, to compare with the pending list.
CHECK_LENGTH = 2
#: Refuse to grow past this. Codes are free to ask for and cost memory.
#: Never more waiting than this: codes are free to ask for and cost memory.
#: The oldest makes room for a new one, so a flood cannot lock a device out.
MAX_PENDING = 50
#: Codes one address may ask for per window. A device asks once per code,
#: every ten minutes, and again after a failure.
PAIR_PER_ADDRESS = 5
PAIR_WINDOW_S = 60.0
#: Ids a device cannot take: the routes beside ``/{device_id}``.
RESERVED = {"pair", "pending"}
@@ -107,6 +125,8 @@ class _Pending(BaseModel):
#: Names the request in the pending list, so it can be adopted without
#: the code being handed to anyone.
request: str = ""
#: Shown by the device and beside its entry in the list. Not a secret.
check: str = ""
#: Wall-clock rather than monotonic: an entry outlives the process that
#: minted it.
expires: float
@@ -164,7 +184,11 @@ class _PendingStore:
return f"{_PENDING_KEY}:{code}"
def _codes(self) -> list[str]:
"""The live codes, the expired ones dropped first."""
"""The live codes, oldest first, the expired ones dropped first.
Oldest first because every code lives as long: the index is scored by
expiry, and the dictionary keeps the order codes were added in.
"""
now = time.time()
if self._redis is None:
for code in [c for c, e in self._local.items() if e.expires < now]:
@@ -173,6 +197,12 @@ class _PendingStore:
self._redis.zremrangebyscore(_PENDING_INDEX, "-inf", now)
return list(cast("list[str]", self._redis.zrange(_PENDING_INDEX, 0, -1)))
def make_room(self, room: int) -> None:
"""Drop the oldest codes until fewer than ``room`` are left."""
codes = self._codes()
for code in codes[: max(0, len(codes) - room + 1)]:
self.drop(code)
def count(self) -> int:
return len(self._codes())
@@ -251,6 +281,9 @@ class PairStarted(BaseModel):
#: Held by the device, never shown. Without it the code alone would let
#: anyone who reads it off the wall collect the credential first.
secret: str
#: Shown beside the code. The pending list shows it next to this request,
#: so whoever adopts it from there can tell it is this screen.
check: str = ""
expires_in: int = PAIR_TTL
@@ -275,6 +308,8 @@ class PendingDevice(BaseModel):
#: What ``POST /devices/{id}/pair`` takes to adopt it without the code.
request: str = ""
#: What the device shows beside its code, to compare before adopting it.
check: str = ""
device: str
name: str = ""
board: str = ""
@@ -289,6 +324,7 @@ class PendingDevice(BaseModel):
def _pending_public(entry: _Pending) -> PendingDevice:
return PendingDevice(
request=entry.request,
check=entry.check,
device=entry.device,
name=entry.name,
board=entry.board,
@@ -356,13 +392,14 @@ def _describe(request: Request) -> str:
return " · ".join(part for part in (agent or "Unknown device", address) if part)
def _mint_at_hub(device_id: str) -> str:
def _mint_at_hub(device_id: str, nonce: int) -> str:
"""Ask the portal for this device's credential.
A device that arrived through the portal cannot reach this instance, so a
token this instance signed would be one it could never present: the portal
verifies what crosses its tunnel against its own key. It mints, we say
which device, and the scope check here decides the rest.
which device and its nonce, and the checks here decide the rest: the
nonce is held against the device's as for a credential minted here.
"""
config = cloud_config.load()
if config is None:
@@ -375,7 +412,7 @@ def _mint_at_hub(device_id: str) -> str:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/panel-tokens/",
headers={"Authorization": f"Bearer {config.token}"},
json={"panel": device_id},
json={"panel": device_id, "pnc": nonce},
timeout=15.0,
)
except httpx.HTTPError as exc:
@@ -403,16 +440,28 @@ def _document(device: DeviceDef, store: DeviceStore) -> DeviceDocument:
)
#: A recheck waiting for a rebuild to finish. One is enough: it reads the
#: devices as they are once it runs.
_rechecks: set[asyncio.Task[None]] = set()
async def _recheck(request: Request) -> None:
"""Validate the flows again: a device node may name this device.
The device is saved either way, so a busy engine is not this request's
failure: the next rebuild validates again.
failure. The check waits for the rebuild in the background instead, since
one that started before this change may have read the devices before it.
"""
controller = getattr(request.app.state, "flow_controller", None)
if controller is not None:
with suppress(RebuildBusy):
await controller.recheck()
if controller is None:
return
try:
await controller.recheck()
except RebuildBusy:
if not _rechecks:
task = asyncio.create_task(controller.recheck(wait=None))
_rechecks.add(task)
task.add_done_callback(_rechecks.discard)
def _changed() -> None:
@@ -433,19 +482,24 @@ def start_pairing(request: Request, body: PairHello | None = None) -> Any:
All this hands out is a code that means nothing until somebody with an
account approves it, so the worst an unwelcome caller achieves is an entry
that expires ten minutes later. Reachable from the internet when this
instance is enrolled with a portal, which is what the cap and the portal's
own per-address limits are between.
instance is enrolled with a portal, which is what the per-address limit,
the cap and the portal's own limits are between. At the cap the oldest
request makes room, so a flood delays a device rather than locking it out.
"""
if _pending.count() >= MAX_PENDING:
if too_many(request, "pair", PAIR_PER_ADDRESS, PAIR_WINDOW_S):
raise HTTPException(
status_code=429, detail="Too many devices are waiting to be paired"
status_code=429,
detail="Too many pairing codes asked for from here. Try again shortly.",
headers={"Retry-After": str(int(PAIR_WINDOW_S))},
)
_pending.make_room(MAX_PENDING)
remote = cloud_config.via_portal(request.headers)
hello = body or PairHello()
entry = _Pending(
secret=secrets.token_urlsafe(16),
request=secrets.token_urlsafe(9),
check="".join(secrets.choice(CODE_ALPHABET) for _ in range(CHECK_LENGTH)),
expires=time.time() + PAIR_TTL,
device=_describe(request),
name=hello.name,
@@ -462,7 +516,7 @@ def start_pairing(request: Request, body: PairHello | None = None) -> Any:
break
else:
raise HTTPException(status_code=503, detail="Could not mint a pairing code")
return PairStarted(code=code, secret=entry.secret)
return PairStarted(code=code, secret=entry.secret, check=entry.check)
def poll_pairing(code: str, secret: str = "") -> Any:
@@ -642,7 +696,7 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
detail=f"A report is at most {REPORT_MAX // 1024} KB",
)
try:
report = DeviceReport.model_validate_json(bytes(raw))
report, left_out = read_report(bytes(raw))
except ValidationError as exc:
raise HTTPException(
status_code=422,
@@ -650,6 +704,8 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
include_url=False, include_input=False, include_context=False
),
)
for part in left_out:
logger.warning("Device '%s' reported %s, left out", device_id, part)
first = await run_in_threadpool(store.report, device_id, report)
if (
first
@@ -666,7 +722,8 @@ async def put_report(device_id: str, request: Request, store: DeviceStoreDep) ->
)
_changed()
await _recheck(request)
return Message(message=f"{len(report.ports)} ports reported")
said = f"{len(report.ports)} ports reported"
return Message(message=f"{said}, {len(left_out)} left out" if left_out else said)
@router.put(
@@ -719,15 +776,17 @@ def _adopt(
) -> str:
"""Mint the credential the device behind ``entry`` collects on its next poll.
One minted here names the approver, so what the device does stays
attributable to a person. One minted by the portal names the account this
instance was enrolled with instead.
Either one carries the device's nonce, so unpairing or deleting the
device refuses it. One minted here also names the approver, for the
record: the device acts as itself and not as that account, so deleting
the account leaves the device paired.
"""
nonce = store.nonce(device_id)
if entry.remote:
entry.token = _mint_at_hub(device_id)
entry.token = _mint_at_hub(device_id, nonce)
else:
entry.token = security.create_panel_token(
device_id, user.id, timedelta(days=TOKEN_DAYS), store.nonce(device_id)
device_id, user.id, timedelta(days=TOKEN_DAYS), nonce
)
entry.device_id = device_id
_pending.save(code, entry)
@@ -791,9 +850,9 @@ async def unpair_device(device_id: str, store: DeviceStoreDep) -> Any:
The screen hanging there is refused on its next request and goes back to
showing a pairing code, while its dashboards and their arrangement stay.
A credential the portal minted for a remote screen carries no nonce, so
this does not reach it. That one is revoked at the hub.
The same holds for one the portal minted. An older portal minted without
the nonce, and such a credential stands only while the nonce is still 0,
so this refuses it too.
"""
if await run_in_threadpool(store.find, device_id) is None:
raise _not_found(device_id)