From e96183bba34c85789e58d261988ebf54812ad744 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 21:26:34 +0200 Subject: [PATCH] Add /api/amber/verify-pin: server-side child-PIN check (#13/#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit profiles.pinHash is hidden by design (a readable hash = offline brute force of a 4-digit PIN), which left a device that never saw the PIN set — the TV signing in after the parent set it on the PC — with nothing to verify against: the gate always answered "wrong PIN" there. This route compares the candidate PIN server-side and answers yes/no; the hash never leaves the server, and failures are rate-limited (5 per 5 minutes per account+profile, tracked in the shared app store — file-scope state doesn't survive the per-handler JSVM isolation). The app mirrors a successful verify into local secure storage, so each device pays the round-trip once per profile and works offline after. Co-Authored-By: Claude Opus 4.8 --- pb_hooks/profile_pin.pb.js | 69 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 pb_hooks/profile_pin.pb.js diff --git a/pb_hooks/profile_pin.pb.js b/pb_hooks/profile_pin.pb.js new file mode 100644 index 0000000..bd1cf2a --- /dev/null +++ b/pb_hooks/profile_pin.pb.js @@ -0,0 +1,69 @@ +/// + +// 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"))