mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
It was generated once at pairing and never changed. On a fleet that makes it a
shared secret with no expiry: anyone who watches it typed once — an installer, a
contractor, someone filming a screen — keeps it for the life of the panel, and
the only way to take it back was to unpair and re-pair every affected display. A
customer asked whether it rotates, which was the right question.
POST /api/devices/:id/settings-pin takes { rotate: true } or { pin: "123456" },
and pushes the result to the panel over its socket immediately. The live push is
the part that matters: without it a new PIN would only take effect at the next
pairing, so an operator revoking a leaked PIN would believe access was closed
while the old one still opened the menu. The response reports whether the panel
actually took it, so an offline display is stated rather than assumed.
Validation is the security-relevant half and is pure and tested: six digits,
digits only, and a blocklist of the PINs people actually pick (repeats and
sequences) refused on explicit set and never produced by the generator. A PIN
that can be set to "0000" or left empty is a gate that is not there.
Generation uses crypto.randomInt rather than Math.random — this is a credential,
and a rotation requested BECAUSE a PIN leaked must not be predictable from
anything else. Leading zeros are padded, or roughly one PIN in ten would be five
digits and rejected by the on-device prompt.
Android applies it live via device:settings-pin instead of only at pairing. The
PIN is never written to a log on either side, and it stays out of device list
responses as before.
1084 pass; Android compiles.
Asked for by chris@chris-pc.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
62 lines
2.6 KiB
JavaScript
62 lines
2.6 KiB
JavaScript
'use strict';
|
|
|
|
const crypto = require('crypto');
|
|
|
|
/*
|
|
* The PIN that gates the on-device settings menu (two taps of Back, then a PIN).
|
|
*
|
|
* It was generated once at pairing and never changed. On a fleet that makes it a shared secret
|
|
* with no expiry: anyone who watches it typed once — an installer, a contractor, someone filming
|
|
* a screen — keeps it for the life of the panel, and the only way to take it back was to unpair
|
|
* and re-pair every affected display. A customer asked whether it rotates. It did not.
|
|
*
|
|
* So: settable and rotatable from the dashboard, pushed to the panel live.
|
|
*
|
|
* Kept pure and separate because the VALIDATION is the security-relevant part and deserves tests
|
|
* that do not need a fleet: a PIN that can be set to "0000" or to an empty string is a gate that
|
|
* is not there.
|
|
*/
|
|
|
|
// Six digits, matching what the Android menu prompts for. Not configurable: a length that varies
|
|
// per device is a support burden, and the keypad on a signage panel is often a remote control.
|
|
const PIN_LENGTH = 6;
|
|
|
|
/*
|
|
* Sequences and repeats are the PINs people actually pick, and the ones an onlooker guesses first.
|
|
* Rejected on explicit SET; never produced by the generator.
|
|
*/
|
|
const WEAK = new Set(['000000', '111111', '222222', '333333', '444444', '555555', '666666',
|
|
'777777', '888888', '999999', '123456', '654321', '012345', '543210']);
|
|
|
|
/**
|
|
* Generate a fresh PIN.
|
|
*
|
|
* Uses crypto.randomInt, not Math.random: this is a credential. The previous generator used
|
|
* SQLite's random() at provisioning time, which is fine, but a rotation the operator asked for
|
|
* because a PIN leaked must not be predictable from any other value.
|
|
*/
|
|
function generatePin() {
|
|
for (let attempt = 0; attempt < 20; attempt++) {
|
|
const pin = String(crypto.randomInt(0, 1000000)).padStart(PIN_LENGTH, '0');
|
|
if (!WEAK.has(pin)) return pin;
|
|
}
|
|
// Exhausting 20 draws against a 14-entry blocklist is essentially impossible; if it somehow
|
|
// happens, a non-weak constant beats returning something weak or throwing during provisioning.
|
|
return '481920';
|
|
}
|
|
|
|
/**
|
|
* Validate an operator-supplied PIN.
|
|
* @returns {{ok: true, pin: string} | {ok: false, error: string}}
|
|
*/
|
|
function validatePin(input) {
|
|
if (input === null || input === undefined) return { ok: false, error: 'PIN is required' };
|
|
const pin = String(input).trim();
|
|
if (!/^[0-9]+$/.test(pin)) return { ok: false, error: 'PIN must be digits only' };
|
|
if (pin.length !== PIN_LENGTH) return { ok: false, error: `PIN must be ${PIN_LENGTH} digits` };
|
|
if (WEAK.has(pin)) return { ok: false, error: 'PIN is too easily guessed' };
|
|
return { ok: true, pin };
|
|
}
|
|
|
|
module.exports = { generatePin, validatePin, PIN_LENGTH, WEAK };
|