feat: add device passkeys for operator access (Closes #485)
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 13:14:18 +00:00
1 parent 131848cc43
commit ced644b275
15 files changed
+1228 -33

No files matched your search

+12 -2
View File
@@ -110,7 +110,10 @@ def idle_timeout_seconds() -> int:
def issue_session(
now: int | None = None, *, device_label: str = "This device"
now: int | None = None,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
@@ -127,7 +130,10 @@ def issue_session(
encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate(
session.session_id, session.expires_at, device_label=device_label
session.session_id,
session.expires_at,
device_label=device_label,
management_id=management_id,
)
return f"{encoded}.{signature}", session
@@ -187,6 +193,10 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def session_management_id(session: Session) -> str:
return await asyncio.to_thread(_session_store().management_id, session.session_id)
async def touch_session(session: Session) -> bool:
return await asyncio.to_thread(
_session_store().touch,
+6 -1
View File
@@ -8,6 +8,8 @@ import re
from dataclasses import dataclass
from pathlib import Path
import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
@@ -29,7 +31,10 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
for source in sources:
path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
return b"".join(chunks)
source = b"".join(chunks)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()
return minified + f';"source-sha256:{revision}";'.encode()
def build_frontend(frontend_dir: Path) -> FrontendBuild:
+306 -1
View File
@@ -21,7 +21,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import dashboard_auth, gitea_proxy
from src import dashboard_auth, gitea_proxy, passkeys
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import (
@@ -40,6 +40,7 @@ from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
from src.models import Issue, Milestone, PullRequest, Repo, User
from src.passkey_store import PasskeyStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute
from src.later_store import LaterStore
@@ -199,12 +200,57 @@ class DashboardSignIn(BaseModel):
class FreshAuthorization(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024)
action: Literal[
"merge_pull",
"close_issue",
"revoke_device",
"revoke_all_sessions",
"enroll_passkey",
]
target: str = Field(min_length=1, max_length=255)
class PasskeyCeremony(BaseModel):
challenge: str = Field(min_length=20, max_length=200)
credential: dict = Field()
class PasskeyAuthentication(PasskeyCeremony):
device_label: str = Field(default="This device", min_length=1, max_length=64)
action: Literal[
"sign_in", "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
] = "sign_in"
target: str = Field(default="dashboard", min_length=1, max_length=255)
class PasskeyAuthorizationTarget(BaseModel):
action: Literal[
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
]
target: str = Field(min_length=1, max_length=255)
class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
pass
def _passkey_store() -> PasskeyStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
database = os.getenv(
"STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3")
)
return PasskeyStore(database, clock=time.time)
def _passkey_relying_party(request: Request) -> tuple[str, str]:
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
origin = os.getenv(
"STACKCHAIN_PASSKEY_ORIGIN",
f"{request.url.scheme}://{request.url.netloc}",
)
return rp_id, origin
def _login_attempt_store() -> LoginAttemptStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return LoginAttemptStore(
@@ -696,6 +742,13 @@ async def require_operator_session(request: Request, call_next):
}
or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST")
or (
path in {
"/api/v1/passkeys/authentication/options",
"/api/v1/passkeys/authentication/verify",
}
and request.method == "POST"
)
)
session = None
session_reason = None
@@ -969,6 +1022,256 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
)
@app.post("/api/v1/passkeys/registration/options", status_code=201)
async def create_passkey_registration_options(
request: Request,
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
await _require_step_up(
request,
step_up_grant,
action="enroll_passkey",
target="current_device",
)
rp_id, _origin = _passkey_relying_party(request)
store = _passkey_store()
existing = await asyncio.to_thread(store.all)
options, challenge = passkeys.registration_options(
rp_id=rp_id,
excluded=[item.credential_id for item in existing],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="registration",
action="enroll_passkey",
target="current_device",
)
return JSONResponse(
options,
status_code=201,
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/passkeys/registration/verify", status_code=201)
async def verify_passkey_registration(payload: PasskeyCeremony, request: Request):
try:
challenge = passkeys.decode(payload.challenge)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="registration",
action="enroll_passkey",
target="current_device",
)
if not valid:
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_registration,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
current = next(device for device in devices if device.current)
await asyncio.to_thread(
store.register,
credential_id=verified.credential_id,
public_key=verified.credential_public_key,
sign_count=verified.sign_count,
device_label=current.device_label,
management_id=current.management_id,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
return JSONResponse(
{"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"}
)
@app.post("/api/v1/passkeys/authentication/options")
async def create_passkey_authentication_options(request: Request):
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
options, challenge = passkeys.authentication_options(
rp_id=rp_id,
credentials=[item.credential_id for item in credentials],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
)
return JSONResponse(options, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/passkeys/authorization/options")
async def create_passkey_authorization_options(
payload: PasskeyAuthorizationTarget, request: Request
):
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
options, challenge = passkeys.authentication_options(
rp_id=rp_id,
credentials=[item.credential_id for item in credentials],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="authorization",
action=payload.action,
target=payload.target,
)
return JSONResponse(options, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/passkeys/authorization/verify", status_code=201)
async def verify_passkey_authorization(
payload: PasskeyAuthorization, request: Request
):
try:
challenge = passkeys.decode(payload.challenge)
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="authorization",
action=payload.action,
target=payload.target,
)
stored = await asyncio.to_thread(store.get, credential_id)
if not valid or stored is None:
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_authentication,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
)
if not updated:
raise ValueError("stale passkey counter")
grant = await dashboard_auth.issue_step_up(
request.state.dashboard_session,
action=payload.action,
target=payload.target,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=401, detail="Passkey authorization failed") from exc
return JSONResponse(
{"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS},
status_code=201,
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/passkeys/authentication/verify")
async def verify_passkey_authentication(
payload: PasskeyAuthentication, request: Request, response: Response
):
if payload.action != "sign_in" or payload.target != "dashboard":
raise HTTPException(status_code=400, detail="Invalid passkey sign-in target")
try:
challenge = passkeys.decode(payload.challenge)
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
)
stored = await asyncio.to_thread(store.get, credential_id)
if not valid or stored is None:
raise HTTPException(status_code=401, detail="Passkey sign-in failed")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_authentication,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
)
if not updated:
raise ValueError("stale passkey counter")
await dashboard_auth.revoke_managed_session(stored.management_id)
signed, session = await asyncio.to_thread(
dashboard_auth.issue_session,
device_label=stored.device_label,
management_id=stored.management_id,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc
path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie(
dashboard_auth.SESSION_COOKIE,
signed,
max_age=max_age,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.set_cookie(
dashboard_auth.CSRF_COOKIE,
session.csrf,
max_age=max_age,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": True, "method": "passkey"}
@app.get("/api/v1/session")
async def session_status(request: Request):
session = request.state.dashboard_session
@@ -1196,6 +1499,7 @@ async def revoke_active_device(
target = next(
(device for device in devices if device.management_id == management_id), None
)
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
revoked = await dashboard_auth.revoke_managed_session(management_id)
except dashboard_auth.SessionStoreError:
return JSONResponse(
@@ -1223,6 +1527,7 @@ async def sign_out_all_devices(
target="all",
)
try:
await asyncio.to_thread(_passkey_store().revoke_all)
await dashboard_auth.revoke_all_sessions()
except dashboard_auth.SessionStoreError:
return JSONResponse(
+199
View File
@@ -0,0 +1,199 @@
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from src.session_store import SessionStoreError
@dataclass(frozen=True)
class StoredPasskey:
credential_id: bytes
public_key: bytes
sign_count: int
device_label: str
management_id: str
class PasskeyStore:
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
self.path = Path(path)
self.clock = clock
@staticmethod
def _digest(value: bytes | str) -> str:
raw = value if isinstance(value, bytes) else value.encode()
return hashlib.sha256(raw).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=0.1)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_credentials (
credential_id BLOB PRIMARY KEY,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def issue_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
ttl_seconds: int = 120,
) -> None:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO passkey_challenges("
"challenge_hash, session_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now + max(1, ttl_seconds),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def consume_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
cursor = connection.execute(
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
"AND expires_at > ?",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def register(
self,
*,
credential_id: bytes,
public_key: bytes,
sign_count: int,
device_label: str,
management_id: str,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
sign_count,
device_label,
management_id,
int(self.clock()),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"FROM passkey_credentials ORDER BY created_at DESC"
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_management_id(self, management_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
+87
View File
@@ -0,0 +1,87 @@
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
import base64
import json
import secrets
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
def encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=rp_id,
rp_name="Stackchain Dashboard",
user_name="stackchain-operator",
user_display_name="Stackchain operator",
challenge=challenge,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in (excluded or [])
],
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
return json.loads(options_to_json(options)), challenge
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_authentication_options(
rp_id=rp_id,
challenge=challenge,
allow_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
return json.loads(options_to_json(options)), challenge
def verify_registration(
*, credential: dict, challenge: bytes, rp_id: str, origin: str
):
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=True,
)
def verify_authentication(
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
):
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=stored.public_key,
credential_current_sign_count=stored.sign_count,
require_user_verification=True,
)
+20 -2
View File
@@ -117,7 +117,12 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
def activate(
self, session_id: str, expires_at: int, *, device_label: str = "This device"
self,
session_id: str,
expires_at: int,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> None:
label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock())
@@ -133,7 +138,7 @@ class SessionStore:
(
self._digest(session_id),
expires_at,
secrets.token_urlsafe(18),
management_id or secrets.token_urlsafe(18),
label,
now,
now,
@@ -241,6 +246,19 @@ class SessionStore:
for row in rows
]
def management_id(self, session_id: str) -> str:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None:
raise SessionStoreError("Session is no longer active")
return row[0]
def revoke_managed(self, management_id: str) -> bool:
try:
with self._connect() as connection:
+2 -2
View File
@@ -15,8 +15,8 @@ LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
<body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""