Skip to content

Commit f2383b2

Browse files
committed
pin on backend
1 parent f206880 commit f2383b2

5 files changed

Lines changed: 253 additions & 43 deletions

File tree

‎robot_orchestrator/paths.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def generated_secrets_file(self) -> Path:
4141
def admin_file(self) -> Path:
4242
return self.secrets_dir / "admin.json"
4343

44+
@property
45+
def settings_pin_file(self) -> Path:
46+
return self.secrets_dir / "settings_pin.json"
47+
4448
@property
4549
def repos_dir(self) -> Path:
4650
return self.state_dir / "repos"

‎robot_orchestrator/web/app.py‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from robot_orchestrator.paths import Paths
1414
from robot_orchestrator.web import auth, routes_api, routes_sse
1515
from robot_orchestrator.web.context import AdminContext
16+
from robot_orchestrator.web.settings_pin import PinError, PinInvalid, PinLocked, SettingsPin
1617

1718
LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
1819
STATIC_DIR = Path(__file__).resolve().parent / "static"
@@ -23,6 +24,15 @@ class LoginRequest(BaseModel):
2324
password: str
2425

2526

27+
class PinRequest(BaseModel):
28+
pin: str | None = None
29+
30+
31+
class PinChangeRequest(BaseModel):
32+
current: str | None = None
33+
new: str
34+
35+
2636
class LoginResponse(BaseModel):
2737
access_token: str
2838
expires_in: int
@@ -98,6 +108,7 @@ def create_app(
98108
) -> FastAPI:
99109
app = FastAPI()
100110
throttle = auth.LoginThrottle()
111+
settings_pin = SettingsPin(paths)
101112
bearer = HTTPBearer(auto_error=False)
102113

103114
def require_auth(
@@ -157,16 +168,60 @@ def boot_state(request: Request):
157168
"operator_url": context.operator_url,
158169
}
159170

160-
@app.post("/boot/api/session", include_in_schema=False)
161-
def boot_session(request: Request):
171+
def settings_guard(request: Request) -> None:
162172
require_loopback(request)
163173
# a tunnel or reverse proxy ends up as a loopback peer too, but it always adds forwarding headers
164174
if any(h in request.headers for h in ("x-forwarded-for", "forwarded", "x-real-ip")):
165175
raise HTTPException(status_code=403, detail="direct loopback only")
166176
if not (context and context.boot_progress and context.boot_progress.finished_at):
167177
raise HTTPException(status_code=409, detail="boot is not finished yet")
168-
token = auth.issue_token(jwt_secret, "boot-settings", jwt_ttl_s)
169-
return {"access_token": token, "expires_in": jwt_ttl_s}
178+
179+
def settings_token() -> dict:
180+
return {"access_token": auth.issue_token(jwt_secret, "boot-settings", jwt_ttl_s), "expires_in": jwt_ttl_s}
181+
182+
def check_pin(pin: str | None) -> None:
183+
try:
184+
settings_pin.verify(pin or "")
185+
except PinLocked as e:
186+
raise HTTPException(
187+
status_code=429, detail={"message": "too many attempts", "retry_after": int(e.retry_after) + 1}
188+
)
189+
except PinInvalid as e:
190+
raise HTTPException(status_code=401, detail={"message": "wrong pin", "attempts_left": e.attempts_left})
191+
192+
@app.get("/boot/api/pin", include_in_schema=False)
193+
def boot_pin_status(request: Request):
194+
settings_guard(request)
195+
return {"set": settings_pin.is_set(), "locked_for": int(settings_pin.locked_for())}
196+
197+
@app.post("/boot/api/pin/setup", include_in_schema=False)
198+
def boot_pin_setup(body: PinRequest, request: Request):
199+
settings_guard(request)
200+
if settings_pin.is_set():
201+
raise HTTPException(status_code=409, detail="pin is already set")
202+
try:
203+
settings_pin.set(body.pin)
204+
except PinError as e:
205+
raise HTTPException(status_code=400, detail=str(e))
206+
return settings_token()
207+
208+
@app.post("/boot/api/pin/change", include_in_schema=False)
209+
def boot_pin_change(body: PinChangeRequest, request: Request):
210+
settings_guard(request)
211+
check_pin(body.current)
212+
try:
213+
settings_pin.set(body.new)
214+
except PinError as e:
215+
raise HTTPException(status_code=400, detail=str(e))
216+
return settings_token()
217+
218+
@app.post("/boot/api/session", include_in_schema=False)
219+
def boot_session(body: PinRequest, request: Request):
220+
settings_guard(request)
221+
if not settings_pin.is_set():
222+
raise HTTPException(status_code=409, detail="pin is not set")
223+
check_pin(body.pin)
224+
return settings_token()
170225

171226
@app.get("/boot/api/stream", include_in_schema=False)
172227
async def boot_stream(request: Request):
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import json
2+
import re
3+
import threading
4+
import time
5+
6+
from robot_orchestrator.paths import Paths
7+
from robot_orchestrator.wal.atomic import atomic_write
8+
from robot_orchestrator.web.auth import hash_password, verify_password_hash
9+
10+
MAX_FAILURES = 5
11+
LOCKOUT_S = 30.0
12+
_FORMAT = re.compile(r"^\d{4}$")
13+
14+
15+
class PinError(Exception):
16+
pass
17+
18+
19+
class PinLocked(PinError):
20+
def __init__(self, retry_after: float):
21+
super().__init__("too many attempts")
22+
self.retry_after = retry_after
23+
24+
25+
class PinInvalid(PinError):
26+
def __init__(self, attempts_left: int):
27+
super().__init__("wrong pin")
28+
self.attempts_left = attempts_left
29+
30+
31+
class SettingsPin:
32+
"""The 4-digit PIN guarding the settings modal on the robot's own screen.
33+
34+
It lives next to the other secrets so it survives power loss; the kiosk browser's
35+
profile is on tmpfs and cannot hold it.
36+
"""
37+
38+
def __init__(self, paths: Paths, clock=time.monotonic):
39+
self._path = paths.settings_pin_file
40+
self._clock = clock
41+
self._lock = threading.Lock()
42+
self._failures = 0
43+
self._locked_until = 0.0
44+
45+
def is_set(self) -> bool:
46+
return self._path.exists()
47+
48+
def locked_for(self) -> float:
49+
with self._lock:
50+
return max(0.0, self._locked_until - self._clock())
51+
52+
def set(self, pin: str) -> None:
53+
if not _FORMAT.match(pin or ""):
54+
raise PinError("pin must be 4 digits")
55+
salt, digest = hash_password(pin)
56+
self._path.parent.mkdir(parents=True, exist_ok=True)
57+
atomic_write(self._path, json.dumps({"salt_hex": salt.hex(), "hash_hex": digest.hex()}).encode("utf-8"))
58+
with self._lock:
59+
self._failures = 0
60+
self._locked_until = 0.0
61+
62+
def verify(self, pin: str) -> None:
63+
"""Returns normally on a correct PIN, raises PinLocked / PinInvalid otherwise."""
64+
with self._lock:
65+
now = self._clock()
66+
if self._locked_until > now:
67+
raise PinLocked(self._locked_until - now)
68+
record = self._load()
69+
ok = bool(record) and isinstance(pin, str) and verify_password_hash(
70+
pin, bytes.fromhex(record["salt_hex"]), bytes.fromhex(record["hash_hex"])
71+
)
72+
if ok:
73+
self._failures = 0
74+
return
75+
self._failures += 1
76+
if self._failures >= MAX_FAILURES:
77+
self._failures = 0
78+
self._locked_until = now + LOCKOUT_S
79+
raise PinLocked(LOCKOUT_S)
80+
raise PinInvalid(MAX_FAILURES - self._failures)
81+
82+
def _load(self) -> dict | None:
83+
try:
84+
return json.loads(self._path.read_text(encoding="utf-8"))
85+
except (OSError, ValueError):
86+
return None

‎robot_orchestrator/web/static/boot/index.html‎

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -790,7 +790,6 @@ <h2 id="pin-title">Введите PIN</h2>
790790
const $ = (id) => document.getElementById(id);
791791
const gear = $("gear-btn"), modal = $("settings"), pinBox = $("m-pin"), mainBox = $("m-main");
792792
const tabsEl = $("m-tabs"), content = $("m-content"), oskEl = $("osk");
793-
const PIN_KEY = "supervisor.pin";
794793
const IDLE_MS = 120000;
795794

796795
function h(tag, props, ...children) {
@@ -805,27 +804,20 @@ <h2 id="pin-title">Введите PIN</h2>
805804
return el;
806805
}
807806

808-
/* ---------------- PIN storage (browser-local, on purpose) ---------------- */
809-
function storeGet() { try { return JSON.parse(localStorage.getItem(PIN_KEY) || "null"); } catch (e) { return null; } }
810-
function storeSet(v) { try { localStorage.setItem(PIN_KEY, JSON.stringify(v)); return true; } catch (e) { return false; } }
811-
async function hashPin(pin, salt) {
812-
const data = new TextEncoder().encode(salt + ":" + pin);
813-
if (window.crypto && crypto.subtle) {
814-
const buf = await crypto.subtle.digest("SHA-256", data);
815-
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
816-
}
817-
let x = 5381;
818-
for (const b of data) x = ((x * 33) ^ b) >>> 0;
819-
return "d" + x.toString(16);
820-
}
821-
822807
/* ---------------- session / api ---------------- */
823-
let token = null, tokenAt = 0;
808+
let token = null, tokenAt = 0, sessionPin = null;
809+
async function pinCall(path, body) {
810+
const r = await fetch(path, { method: body === undefined ? "GET" : "POST", cache: "no-store",
811+
headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
812+
let data = {};
813+
try { data = await r.json(); } catch (e) { /* empty body */ }
814+
return { status: r.status, data };
815+
}
824816
async function ensureToken(force) {
825817
if (!force && token && Date.now() - tokenAt < 8 * 60 * 1000) return token;
826-
const r = await fetch("/boot/api/session", { method: "POST" });
827-
if (!r.ok) throw new Error("нет доступа к настройкам (" + r.status + ")");
828-
token = (await r.json()).access_token;
818+
const r = await pinCall("/boot/api/session", { pin: sessionPin });
819+
if (r.status !== 200) throw new Error("нет доступа к настройкам (" + r.status + ")");
820+
token = r.data.access_token;
829821
tokenAt = Date.now();
830822
return token;
831823
}
@@ -872,13 +864,14 @@ <h2 id="pin-title">Введите PIN</h2>
872864
osk.hide();
873865
content.innerHTML = "";
874866
token = null;
867+
sessionPin = null;
875868
}
876869
gear.addEventListener("click", openModal);
877870
$("m-close").addEventListener("click", closeModal);
878871
$("m-lock").addEventListener("click", closeModal);
879872

880873
/* ---------------- PIN pad ---------------- */
881-
let pinMode = "enter", pinBuf = "", pinFirst = "", fails = 0, lockedUntil = 0, lockTimer = null, changing = false;
874+
let pinMode = "enter", pinBuf = "", pinFirst = "", lockedUntil = 0, lockTimer = null, changing = false;
882875
const dots = $("pin-dots").children, hint = $("pin-hint"), title = $("pin-title");
883876

884877
function setHint(text, err) { hint.textContent = text || ""; hint.classList.toggle("err", !!err); }
@@ -898,12 +891,18 @@ <h2 id="pin-title">Введите PIN</h2>
898891
pad.append(key("⌫", backspace, "fn"));
899892
}
900893

901-
function startPinEntry() {
894+
async function startPinEntry() {
902895
changing = false;
903896
pinBox.style.display = "flex"; mainBox.style.display = "none";
904897
$("m-lock").style.display = "none";
905898
pinBuf = ""; pinFirst = "";
906-
pinMode = storeGet() ? "enter" : "setup1";
899+
pinMode = "enter";
900+
paintPinMode();
901+
setHint("Проверяю…");
902+
const r = await pinCall("/boot/api/pin");
903+
if (r.status !== 200) { setHint("Настройки недоступны (" + r.status + ")", true); return; }
904+
pinMode = r.data.set ? "enter" : "setup1";
905+
if (r.data.locked_for > 0) lockedUntil = Date.now() + r.data.locked_for * 1000;
907906
paintPinMode();
908907
}
909908

@@ -946,18 +945,21 @@ <h2 id="pin-title">Введите PIN</h2>
946945
const entered = pinBuf;
947946
pinBuf = ""; renderDots();
948947
if (pinMode === "enter") {
949-
const saved = storeGet();
950-
if (saved && (await hashPin(entered, saved.salt)) === saved.hash) { fails = 0; unlocked = true; showMain(); return; }
951-
fails += 1; shake();
952-
if (fails >= 5) { fails = 0; lockedUntil = Date.now() + 30000; refreshLockout(); }
953-
else setHint("Неверный PIN (" + fails + "/5)", true);
948+
const r = await pinCall("/boot/api/session", { pin: entered });
949+
if (r.status === 200) { sessionPin = entered; token = r.data.access_token; tokenAt = Date.now(); unlocked = true; showMain(); return; }
950+
shake();
951+
if (r.status === 429) { lockedUntil = Date.now() + ((r.data.detail && r.data.detail.retry_after) || 30) * 1000; refreshLockout(); }
952+
else if (r.status === 401) setHint("Неверный PIN. Осталось попыток: " + (r.data.detail && r.data.detail.attempts_left), true);
953+
else setHint("Ошибка " + r.status, true);
954954
} else if (pinMode === "setup1") {
955955
pinFirst = entered; pinMode = "setup2"; paintPinMode();
956956
} else {
957957
if (entered !== pinFirst) { shake(); pinMode = "setup1"; paintPinMode(); setHint("PIN не совпал — введите заново", true); return; }
958-
const salt = Math.random().toString(36).slice(2) + Date.now().toString(36);
959-
const ok = storeSet({ salt, hash: await hashPin(entered, salt) });
960-
if (!ok) { setHint("Не удалось сохранить PIN в браузере", true); return; }
958+
const r = changing
959+
? await pinCall("/boot/api/pin/change", { current: sessionPin, new: entered })
960+
: await pinCall("/boot/api/pin/setup", { pin: entered });
961+
if (r.status !== 200) { setHint("Не удалось сохранить PIN (" + r.status + ")", true); return; }
962+
sessionPin = entered; token = r.data.access_token || null; tokenAt = Date.now();
961963
unlocked = true; changing = false; showMain();
962964
}
963965
}

0 commit comments

Comments
 (0)