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'); +});