From d205a49dfa5deda288bc490895b58458abbfbb39 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Wed, 5 Aug 2026 13:44:15 -0500 Subject: [PATCH] The settings PIN can be rotated and set from the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- .../player/service/WebSocketService.kt | 12 +++ frontend/js/api.js | 1 + frontend/js/i18n/en.js | 7 ++ frontend/js/views/device-detail.js | 29 +++++++ server/lib/settings-pin.js | 61 ++++++++++++++ server/routes/devices.js | 50 ++++++++++++ server/test/settings-pin.test.js | 80 +++++++++++++++++++ 7 files changed, 240 insertions(+) create mode 100644 server/lib/settings-pin.js create mode 100644 server/test/settings-pin.test.js diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index f0e7cdf..92766bc 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -432,6 +432,18 @@ class WebSocketService : Service() { handler.post { try { onPaired?.invoke(id, name) } catch (e: Throwable) { Log.e("WebSocketService", "onPaired cb: ${e.message}") } } } + // A PIN set or rotated from the dashboard takes effect NOW, not at the next pairing. + // Without this an operator who rotated a leaked PIN would believe they had revoked + // access while the old one still opened the menu — worse than not offering it. + safeOn("device:settings-pin") { args -> + val data = args.getOrNull(0) as? org.json.JSONObject ?: return@safeOn + val pin = data.optString("settings_pin", "") + if (pin.isNotEmpty()) { + config.settingsPin = pin + Log.i("WebSocketService", "Settings PIN updated from dashboard") // never log the PIN + } + } + safeOn("device:playlist-update") { args -> val data = args.firstOrNull() as? JSONObject ?: run { Log.w("WebSocketService", "playlist-update with non-JSONObject payload: ${args.firstOrNull()}") diff --git a/frontend/js/api.js b/frontend/js/api.js index 9fb8b82..285fb93 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -41,6 +41,7 @@ export const api = { // and the re-adopt action that applies a snapshot onto a newly-paired device. getRemovedDevices: () => request('/devices/removed'), reAdoptDevice: (id, fingerprint) => request(`/devices/${id}/re-adopt`, { method: 'POST', body: JSON.stringify({ fingerprint }) }), + setDevicePin: (id, body) => request(`/devices/${id}/settings-pin`, { method: 'POST', body: JSON.stringify(body) }), // #109 PiP overlay: push/clear a floating overlay on a device or group. `id` may be a // device id OR a group id (the server resolves + expands). Needs full scope (no-op for JWT). diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 22ecbe0..9e30533 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -491,6 +491,13 @@ export default { 'device.info.uptime': 'Uptime', 'device.info.android_version': 'Android Version', 'device.info.app_version': 'App Version', + 'device.pin.rotate': 'Rotate', + 'device.pin.set': 'Set…', + 'device.pin.rotate_confirm': 'Generate a new settings PIN for this display? The current PIN stops working immediately.', + 'device.pin.set_prompt': 'New 6-digit settings PIN', + 'device.pin.updated_live': 'PIN updated — the display has it now', + 'device.pin.updated_offline': 'PIN saved — the display will pick it up when it reconnects', + 'device.pin.failed': 'Could not update the PIN', 'device.info.settings_pin': 'Settings PIN', 'device.info.settings_pin_hint': 'On-device settings menu (2× Back)', 'device.info.screen_resolution': 'Screen Resolution', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 8ee9b96..655636b 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -410,6 +410,10 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.settings_pin')}
${device.settings_pin || '--'}
${t('device.info.settings_pin_hint')}
+
+ + +
` : ''}
@@ -967,6 +971,31 @@ async function showReAdoptModal(device) { function setupActions(device) { // #104 Preview button + // PIN rotate / set. The response says whether the panel took it LIVE: an offline display + // applies it on its next reconnect, and an operator rotating a leaked PIN needs to know + // which of those happened rather than assuming access is already revoked. + async function applyPin(body, confirmMsg) { + if (confirmMsg && !confirm(confirmMsg)) return; + try { + const r = await api.setDevicePin(device.id, body); + device.settings_pin = r.settings_pin; + const el = document.querySelector('#rotatePinBtn')?.closest('.info-card')?.querySelector('.info-card-value'); + if (el) el.textContent = r.settings_pin; + showToast(r.delivered ? t('device.pin.updated_live') : t('device.pin.updated_offline'), 'success'); + } catch (e) { + showToast(e?.message || t('device.pin.failed'), 'error'); + } + } + + document.getElementById('rotatePinBtn')?.addEventListener('click', () => + applyPin({ rotate: true }, t('device.pin.rotate_confirm'))); + + document.getElementById('setPinBtn')?.addEventListener('click', () => { + const pin = prompt(t('device.pin.set_prompt')); + if (pin === null) return; + applyPin({ pin }); + }); + document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device)); // Screenshot button diff --git a/server/lib/settings-pin.js b/server/lib/settings-pin.js new file mode 100644 index 0000000..0b3b495 --- /dev/null +++ b/server/lib/settings-pin.js @@ -0,0 +1,61 @@ +'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 }; diff --git a/server/routes/devices.js b/server/routes/devices.js index faf455a..88ca81e 100644 --- a/server/routes/devices.js +++ b/server/routes/devices.js @@ -316,6 +316,56 @@ router.put('/:id', (req, res) => { // scoped by checkDeviceOwnership. OUTAGE PROCEDURE (dashboard down): set it by hand via // direct SQLite — `UPDATE devices SET blocked = 1 WHERE id = '';` (0 to // unblock) — same column, same next-register effect. +/* + * Set or rotate the on-device settings PIN. + * + * Body: { pin: "123456" } to set explicitly, or { rotate: true } for a fresh random one. + * + * Was provisioned once at pairing and never changeable, which made it a shared secret with no + * expiry: anyone who watched it typed kept it for the life of the panel, and revoking it meant + * unpairing and re-pairing. Now it can be rotated the moment an installer leaves. + * + * Pushed to the panel immediately over its socket. Without that the new PIN would only take effect + * at the next pairing — so the operator would believe they had revoked access while the old PIN + * still opened the menu, which is worse than not offering the feature. + */ +router.post('/:id/settings-pin', (req, res) => { + const device = checkDeviceOwnership(req, res); + if (!device) return; + + const pinLib = require('../lib/settings-pin'); + let pin; + if (req.body && req.body.rotate) { + pin = pinLib.generatePin(); + } else { + const v = pinLib.validatePin(req.body && req.body.pin); + if (!v.ok) return res.status(400).json({ error: v.error }); + pin = v.pin; + } + + db.prepare("UPDATE devices SET settings_pin = ?, updated_at = strftime('%s','now') WHERE id = ?") + .run(pin, req.params.id); + + // Live push. A panel that is offline picks it up on its next pair/reconnect; the response says + // which happened so the dashboard can tell the operator whether it is in force yet. + let delivered = false; + try { + const io = req.app.get('io'); + if (io) { + const ns = io.of('/device'); + const room = ns.adapter.rooms.get(req.params.id); + if (room && room.size > 0) { + ns.to(req.params.id).emit('device:settings-pin', { settings_pin: pin }); + delivered = true; + } + } + } catch (e) { console.warn(`[settings-pin] push failed: ${e.message}`); } + + // Deliberately NOT logging the PIN itself. + console.log(`[settings-pin] device ${req.params.id} pin ${req.body && req.body.rotate ? 'rotated' : 'set'} by user ${req.user.id} (delivered=${delivered})`); + res.json({ success: true, settings_pin: pin, delivered }); +}); + router.post('/:id/block', (req, res) => { const device = checkDeviceOwnership(req, res); if (!device) return; diff --git a/server/test/settings-pin.test.js b/server/test/settings-pin.test.js new file mode 100644 index 0000000..7072150 --- /dev/null +++ b/server/test/settings-pin.test.js @@ -0,0 +1,80 @@ +'use strict'; + +// The on-device settings PIN was generated once at pairing and never changed. +// +// On a fleet that makes it a shared secret with no expiry: anyone who sees 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 display. A customer asked whether it +// rotates, which is the right question to ask. +// +// The validation is the security-relevant half: a PIN that can be set to "0000" or left empty is a +// gate that is not there. These tests exist so a future "let operators pick any PIN they like" +// change has to argue with them first. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { generatePin, validatePin, PIN_LENGTH, WEAK } = require('../lib/settings-pin'); + +test('a generated PIN is exactly the length the on-device prompt expects', () => { + for (let i = 0; i < 50; i++) { + const p = generatePin(); + assert.equal(p.length, PIN_LENGTH); + assert.match(p, /^[0-9]+$/); + } +}); + +test('generated PINs keep their leading zeros', () => { + // Generated as a number and padded: dropping the pad would emit 5-digit PINs ~10% of the time, + // which the device prompt then refuses. + const seen = new Set(); + for (let i = 0; i < 400; i++) seen.add(generatePin().length); + assert.deepEqual([...seen], [PIN_LENGTH]); +}); + +test('the generator never emits a PIN it would refuse on input', () => { + for (let i = 0; i < 500; i++) assert.ok(!WEAK.has(generatePin())); +}); + +test('generated PINs are not all the same value', () => { + const seen = new Set(); + for (let i = 0; i < 50; i++) seen.add(generatePin()); + assert.ok(seen.size > 40, `expected variety, got ${seen.size} distinct in 50`); +}); + +test('THE GATE: obvious PINs are refused on an explicit set', () => { + for (const weak of ['000000', '111111', '123456', '654321']) { + const r = validatePin(weak); + assert.equal(r.ok, false, `${weak} must be refused`); + } +}); + +test('wrong length is refused — a 4-digit PIN would not open the 6-digit prompt', () => { + assert.equal(validatePin('1234').ok, false); + assert.equal(validatePin('12345678').ok, false); +}); + +test('non-digits are refused, including the ones that look like digits', () => { + assert.equal(validatePin('12 456').ok, false); + assert.equal(validatePin('abcdef').ok, false); + assert.equal(validatePin('12.456').ok, false); + assert.equal(validatePin('-12345').ok, false); +}); + +test('empty and missing are refused rather than silently clearing the gate', () => { + assert.equal(validatePin('').ok, false); + assert.equal(validatePin(null).ok, false); + assert.equal(validatePin(undefined).ok, false); + assert.equal(validatePin(' ').ok, false); +}); + +test('a good PIN is accepted and returned trimmed', () => { + const r = validatePin(' 204815 '); + assert.equal(r.ok, true); + assert.equal(r.pin, '204815'); +}); + +test('a numeric PIN is accepted — the API may hand us a number, not a string', () => { + const r = validatePin(204815); + assert.equal(r.ok, true); + assert.equal(r.pin, '204815'); +});