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:
+44
-15
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user