Testing verify-pin against the live DB showed the correct PIN failing:
the server never held a hash at all. PocketBase silently drops client
PATCHes to hidden fields, so the app's best-effort `PATCH {pinHash}`
had never landed — the device-local verifier masked it on the PC. The
new owner-scoped route writes the hash from superuser context (which
can set hidden fields); pin "" clears it. Hash shape matches the app
and verify-pin: sha256("<profileId>:<pin>") hex.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
101 lines
4.4 KiB
JavaScript
101 lines
4.4 KiB
JavaScript
/// <reference path="../pb_data/types.d.ts" />
|
|
|
|
// 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("<profileId>:<pin>") 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("<profileId>:<pin>") 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"))
|