/// // Server-side child-PIN verification (issues #13/#14 follow-up). // // `profiles.pinHash` is a hidden field *by design*: if any logged-in device // could read it, a child holding the TV could pull the salted SHA-256 and // brute-force a 4-digit PIN offline in milliseconds. The cost of that design: // a device that never saw the PIN being set (a TV signing in after the parent // set the PIN on the PC) has no local verifier and nothing readable to check // against — the gate always said "wrong PIN" there. This route closes the gap: // the client sends the candidate PIN, the server compares against the stored // hash and answers yes/no. The hash never leaves the server, and guessing is // rate-limited (5 failures per 5 minutes per account+profile) so the endpoint // can't be brute-forced either. // // The app mirrors a successful verify into its local secure storage, so each // device pays this round-trip at most once per profile (and works offline // afterwards) — see ProfileService.verifyPin. // ── POST /api/amber/verify-pin ──────────────────────────────────────────────── // Auth: account owner. Body: { profileId, pin }. → { ok: bool } (429 when // rate-limited, with retryAfter seconds). routerAdd("POST", "/api/amber/verify-pin", (e) => { const MAX_FAILS = 5 const WINDOW_SECONDS = 300 const data = new DynamicModel({ profileId: "", pin: "" }) e.bindBody(data) const profileId = (data.profileId || "").trim() const pin = (data.pin || "").trim() if (profileId === "" || !/^\d{4}$/.test(pin)) { return e.json(400, { ok: false, error: "profileId and a 4-digit pin are required" }) } let rec = null try { rec = $app.findRecordById("profiles", profileId) } catch (_) { return e.json(404, { ok: false, error: "profile not found" }) } // Owner-scoped, like the profiles collection rules. if (rec.getString("user") !== e.auth.id) { return e.json(404, { ok: false, error: "profile not found" }) } // Fixed-window rate limit in the app store (shared across the isolated // handler JSVMs — file-scope state is not, see device_auth.pb.js). const store = $app.store() const key = "amber.pinFails." + e.auth.id + "." + profileId const now = Math.floor(Date.now() / 1000) let st = store.get(key) if (st && st.resetAt <= now) st = null if (st && st.count >= MAX_FAILS) { return e.json(429, { ok: false, error: "too many attempts", retryAfter: st.resetAt - now }) } const stored = rec.getString("pinHash") // Same shape the app stores: sha256(":") hex. const ok = stored !== "" && stored === $security.sha256(profileId + ":" + pin) if (ok) { store.set(key, null) } else { store.set(key, { count: (st ? st.count : 0) + 1, resetAt: st ? st.resetAt : now + WINDOW_SECONDS, }) } return e.json(200, { ok: ok }) }, $apis.requireAuth("users")) // ── POST /api/amber/set-pin ─────────────────────────────────────────────────── // Auth: account owner. Body: { profileId, pin } — pin "" clears. Exists because // `pinHash` is hidden and PocketBase **silently drops client writes to hidden // fields**: the app's old best-effort `PATCH {pinHash}` never landed, so the // server never held a hash and verify-pin had nothing to compare (the PC's // local verifier masked this). Hooks run in superuser context, which can write // hidden fields; the hash shape matches the app + verify-pin: // sha256(":") hex. routerAdd("POST", "/api/amber/set-pin", (e) => { const data = new DynamicModel({ profileId: "", pin: "" }) e.bindBody(data) const profileId = (data.profileId || "").trim() const pin = (data.pin || "").trim() if (profileId === "" || (pin !== "" && !/^\d{4}$/.test(pin))) { return e.json(400, { ok: false, error: "profileId and a 4-digit (or empty) pin are required" }) } let rec = null try { rec = $app.findRecordById("profiles", profileId) } catch (_) { return e.json(404, { ok: false, error: "profile not found" }) } if (rec.getString("user") !== e.auth.id) { return e.json(404, { ok: false, error: "profile not found" }) } rec.set("pinHash", pin === "" ? "" : $security.sha256(profileId + ":" + pin)) $app.save(rec) return e.json(200, { ok: true }) }, $apis.requireAuth("users"))