mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
A screen that was still connected and still displaying its pairing code could not be
paired. Reloading the player produced the same code, and the on-screen instruction
("restart the display to get a new code") could not help.
devices.created_at is written once, at first registration, and the row is never recreated:
a player persists its device_id and its pairing code in local storage and re-registers
with them forever. Expiry was measured from created_at, so 15 minutes after first boot the
row became permanently unclaimable while the device kept heartbeating — and a restart
reused the stored identity and reproduced the same code, so there was no way out.
Observed in production: an unclaimed web player, still online and heartbeating, whose row
was created 4 days 20 hours earlier and had been unpairable for all but its first 15
minutes. Prod is carrying several such rows; alpha has some 13 days old.
Key expiry on last_heartbeat instead, falling back to created_at for a row that has never
checked in. That answers the question the operator actually has — is this screen still
there showing me this code? — while keeping the property the expiry exists for: a device
that has genuinely gone away still expires.
Trade-off, taken deliberately: a code stays claimable while its screen is connected rather
than for a fixed 15 minutes. That is what the product implies, since the code is on the
screen the whole time, and guessing is bounded by lib/pair-lockout (5 failures per IP per
15 min) and the 5/min route limit rather than by this TTL.
SERVER-ONLY. The player's device:registered handler reads only device_id and device_token
and has no way to display a server-issued code, so reissuing one would have left fielded
players showing a stale code — strictly worse. This fix needs no player update and
un-strands every already-affected device in the field on deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
45 lines
2.1 KiB
JavaScript
45 lines
2.1 KiB
JavaScript
'use strict';
|
|
|
|
// #87: brute-force hardening for device pairing. The 6-digit pairing code is generated
|
|
// client-side, so the server can't raise its entropy without a player change - but it can
|
|
// (a) lock out an IP after repeated failed claims and (b) expire stale provisioning codes
|
|
// so a code is not claimable indefinitely. Together with the 5/min rate-limit on
|
|
// /api/provision (#88), guessing the ~1M code space becomes infeasible (a locked-out IP
|
|
// gets ~5 tries per 15 min, and each code only lives 15 min).
|
|
|
|
const MAX_FAILS = 5; // consecutive failed claims from an IP before lockout
|
|
const LOCKOUT_MS = 15 * 60 * 1000; // how long the IP is then blocked from /pair
|
|
const PAIRING_TTL_SEC = 15 * 60; // how long a provisioning code stays claimable
|
|
|
|
const failures = new Map(); // ip -> { count, lockedUntil }
|
|
|
|
function isLocked(ip, now = Date.now()) {
|
|
const rec = failures.get(ip);
|
|
return !!(rec && rec.lockedUntil > now);
|
|
}
|
|
|
|
// Record one failed claim from an IP; trip the lockout once MAX_FAILS is reached.
|
|
function recordFailure(ip, now = Date.now()) {
|
|
const rec = failures.get(ip) || { count: 0, lockedUntil: 0 };
|
|
rec.count += 1;
|
|
if (rec.count >= MAX_FAILS) { rec.lockedUntil = now + LOCKOUT_MS; rec.count = 0; }
|
|
failures.set(ip, rec);
|
|
return rec;
|
|
}
|
|
|
|
// A successful pair (or any reason to forgive an IP) clears its failure record.
|
|
function reset(ip) { failures.delete(ip); }
|
|
|
|
// A provisioning code is stale once the device has not been seen for longer than the TTL.
|
|
//
|
|
// The caller passes a LIVENESS timestamp (devices.last_heartbeat, falling back to
|
|
// created_at for a row that has never checked in) — NOT the row's creation time. A player
|
|
// keeps its device_id and its code across restarts and re-registers with them forever, so
|
|
// created_at never advances; keying on it made a still-connected screen permanently
|
|
// unpairable 15 minutes after first boot. See the comment at the call site in server.js.
|
|
function isCodeExpired(lastSeenSec, now = Date.now()) {
|
|
return Math.floor(now / 1000) - lastSeenSec > PAIRING_TTL_SEC;
|
|
}
|
|
|
|
module.exports = { isLocked, recordFailure, reset, isCodeExpired, MAX_FAILS, LOCKOUT_MS, PAIRING_TTL_SEC };
|