diff --git a/server/db/database.js b/server/db/database.js index d4e80a1..1e15421 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -332,6 +332,13 @@ const migrations = [ "ALTER TABLE devices ADD COLUMN system_brightness REAL", "ALTER TABLE devices ADD COLUMN window_brightness REAL", "ALTER TABLE devices ADD COLUMN screen_off_timeout_ms INTEGER", + // The hardware-derived half of a client's fingerprint, kept separately from the identity it + // now presents. Two identical panels produce the same hardware value, so it identifies a + // MODEL, not a unit, and can only ever be a hint for reuniting a wiped panel with its row — + // never the thing a match is decided on. Nullable: clients that predate this send no such + // field, and the lookup falls back to exact-match-only for them. + "ALTER TABLE device_fingerprints ADD COLUMN hw_fingerprint TEXT", + "CREATE INDEX IF NOT EXISTS idx_device_fingerprints_hw ON device_fingerprints(hw_fingerprint)", // Offline alerting is once per OUTAGE, not once per dedup window. This stores the // last_heartbeat value an offline alert was already sent for. Because a device that // reconnects advances last_heartbeat, the marker self-invalidates on recovery — a new diff --git a/server/player/index.html b/server/player/index.html index 8f0e8bc..7ec4d53 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -643,7 +643,14 @@ }); // ==================== Browser Fingerprint ==================== - function generateBrowserFingerprint() { + // Hardware-only identity. Every input below is a property of the MODEL, not the unit: two + // identical panels report the same user agent, the same screen geometry, the same core count + // and the same canvas raster. So this value is shared by every display of that model + // ANYWHERE — it was never an identity, and treating it as one meant a second identical panel + // collided with the first (observed live: two UniFi Pro Displays at DIFFERENT sites both + // producing web-m73u8w-5f). It is kept because it is still a useful HINT for reuniting a + // wiped panel with its own row, but it is no longer proof of which unit is calling. + function generateHardwareFingerprint() { const components = [ navigator.userAgent, navigator.language, @@ -675,6 +682,31 @@ return 'web-' + Math.abs(hash).toString(36) + '-' + str.length.toString(36); } + // The identity the server matches on: hardware PLUS a random per-INSTALL salt, so two + // identical panels are distinguishable from the first connection — which hardware alone can + // never be. The salt is minted once and kept in localStorage. Clearing storage mints a new + // one, which is correct: that IS a new install, and the hw_fingerprint sent alongside is what + // lets the server offer the old row back when it can do so unambiguously. + // + // Falls back to the bare hardware value when storage is unavailable (private mode, a locked + // down kiosk). That reintroduces the collision for those clients alone rather than leaving + // them with no identity at all, and the server treats an ambiguous hardware value as an + // unknown device and provisions a fresh one — the safe outcome either way. + function generateBrowserFingerprint() { + const hw = generateHardwareFingerprint(); + let salt = null; + try { + salt = localStorage.getItem('st_install_id'); + if (!salt) { + const buf = new Uint8Array(16); + (window.crypto || window.msCrypto).getRandomValues(buf); + salt = Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join(''); + localStorage.setItem('st_install_id', salt); + } + } catch (e) { salt = null; } + return salt ? hw + '-' + salt.slice(0, 16) : hw; + } + // ==================== Boot ==================== // Function used by connect button and auto-connect @@ -1383,8 +1415,11 @@ data.client_version = PLAYER_VERSION; data.platform = browserPlatform(); data.contract_version = 'v4'; - // Browser fingerprint (survives localStorage clear) + // Device identity. `fingerprint` is per-INSTALL and is what the server matches on. + // `hw_fingerprint` is the old hardware-only value, sent alongside so a panel whose storage + // was wiped can still be rematched to its row — but only when that value is unambiguous. data.fingerprint = generateBrowserFingerprint(); + data.hw_fingerprint = generateHardwareFingerprint(); console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`); socket.emit('device:register', data); } diff --git a/server/test/fingerprint-identity-collision.test.js b/server/test/fingerprint-identity-collision.test.js new file mode 100644 index 0000000..fd7d3d0 --- /dev/null +++ b/server/test/fingerprint-identity-collision.test.js @@ -0,0 +1,206 @@ +'use strict'; + +// A display's fingerprint used to be derived ONLY from hardware traits — user agent, screen +// geometry, colour depth, timezone, core count, platform, canvas raster. Every one of those +// describes a MODEL, not a unit. Two identical panels therefore produced the same value, and the +// server treated that value as an identity. +// +// Observed live: two UniFi Pro Displays at DIFFERENT sites both produced web-m73u8w-5f. The +// second one could not be onboarded at all — the server saw a known identity with a live socket +// and refused it, ten times in a row. That is the benign half. The other half is that the guard +// which refused it is a liveness check: had the first display been offline, the second would have +// been handed that row's identity, a freshly minted token, and its playlist and content. The +// fingerprint lookup is global — not scoped to a workspace or an owner — so "identical hardware" +// is the only precondition. +// +// The rules pinned here: +// 1. Two installs on identical hardware get DIFFERENT identities. +// 2. The hardware value is still sent, but only ever MIGRATES a caller that has already proved +// itself by token onto its OWN row. A caller without credentials never resolves through it, +// however few rows it appears to match — "exactly one row" means one row was recorded, not +// that one display exists, and that distinction is the whole bug. Such a caller is +// provisioned a new device: one pairing code, and it cannot be wrong. +// 3. Clients that send no hardware value (older players, and the APK/.wgt whose fingerprint is +// a genuinely per-unit hardware id) behave exactly as they did before. + +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const DATA_DIR = path.join(os.tmpdir(), 'st-fpid-' + crypto.randomBytes(4).toString('hex')); +fs.mkdirSync(path.join(DATA_DIR, 'db'), { recursive: true }); +process.env.DATA_DIR = DATA_DIR; +process.env.SELF_HOSTED = 'true'; +process.env.NODE_ENV = 'test'; + +const { db } = require('../db/database'); +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); + +after(() => { try { fs.rmSync(DATA_DIR, { recursive: true, force: true }); } catch { /* */ } }); + +// ---------------------------------------------------------------- client: distinct identities + +// Run the real client function against a fake localStorage, one per simulated panel. +function makePanel(hw, store = {}) { + const start = HTML.indexOf('function generateBrowserFingerprint()'); + assert.notEqual(start, -1); + let depth = 0, end = -1; + for (let j = HTML.indexOf('{', start); j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; } + } + const src = HTML.slice(start, end); + const scope = { + generateHardwareFingerprint: () => hw, + localStorage: { + getItem: (k) => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, + }, + window: { crypto: { getRandomValues: (b) => crypto.randomFillSync(b) } }, + }; + const fn = new Function(...Object.keys(scope), `${src} return generateBrowserFingerprint;`)(...Object.values(scope)); + return { fp: fn, store }; +} + +test('THE BUG: two identical panels no longer share an identity', () => { + const HW = 'web-m73u8w-5f'; // the value both UniFi displays actually produced + const a = makePanel(HW), b = makePanel(HW); + assert.notEqual(a.fp(), b.fp(), 'identical hardware, different identities'); + assert.ok(a.fp().startsWith(HW), 'the hardware value is still recognisable inside it'); +}); + +test('an identity is stable across reloads of the same install', () => { + const p = makePanel('web-m73u8w-5f'); + assert.equal(p.fp(), p.fp(), 'same call twice'); + const again = makePanel('web-m73u8w-5f', p.store); // same storage = same install + assert.equal(again.fp(), p.fp(), 'a reload keeps the identity'); +}); + +test('clearing storage mints a new identity — that IS a new install', () => { + const p = makePanel('web-m73u8w-5f'); + const before = p.fp(); + delete p.store.st_install_id; + assert.notEqual(p.fp(), before); +}); + +test('storage being unavailable degrades to hardware rather than to nothing', () => { + const start = HTML.indexOf('function generateBrowserFingerprint()'); + let depth = 0, end = -1; + for (let j = HTML.indexOf('{', start); j < HTML.length; j++) { + if (HTML[j] === '{') depth++; + else if (HTML[j] === '}' && --depth === 0) { end = j + 1; break; } + } + const scope = { + generateHardwareFingerprint: () => 'web-hw', + localStorage: { getItem() { throw new Error('denied'); }, setItem() { throw new Error('denied'); } }, + window: { crypto: { getRandomValues: (b) => crypto.randomFillSync(b) } }, + }; + const fn = new Function(...Object.keys(scope), `${HTML.slice(start, end)} return generateBrowserFingerprint;`)(...Object.values(scope)); + assert.equal(fn(), 'web-hw', 'still identifies itself; the server treats ambiguity as unknown'); +}); + +// ---------------------------------------------------------------- server: the ambiguity rule + +const mkDevice = (name) => { + const id = crypto.randomUUID(); + db.prepare(`INSERT INTO devices (id,name,status,created_at) VALUES (?,?,'offline',strftime('%s','now'))`).run(id, name); + return id; +}; +const addFp = (fp, deviceId, hw) => + db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id, hw_fingerprint) VALUES (?,?,?)').run(fp, deviceId, hw); + +// The server's resolution rule, exercised directly. `provenDeviceId` is non-null only when the +// caller authenticated with a valid device_id + token. +function resolve(fingerprint, hw, provenDeviceId = null) { + let existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint); + if (!existing && hw && provenDeviceId) { + const c = db.prepare( + 'SELECT * FROM device_fingerprints WHERE (hw_fingerprint = ? OR fingerprint = ?) AND device_id = ?') + .all(hw, hw, provenDeviceId); + if (c.length === 1) existing = c[0]; + } + return existing; +} + +test('the hardware column exists — the migration is wired up', () => { + const cols = db.prepare('PRAGMA table_info(device_fingerprints)').all().map(c => c.name); + assert.ok(cols.includes('hw_fingerprint')); +}); + +test('the SHIPPED handler enforces this, not just resolve() above', () => { + // resolve() mirrors the server's rule; without this the whole server half of the file would + // pass against an unfixed deviceSocket.js. + const src = fs.readFileSync(path.join(__dirname, '..', 'ws', 'deviceSocket.js'), 'utf8'); + const i = src.indexOf('hw_fingerprint && tokenProven'); + assert.notEqual(i, -1, 'the hint is gated on the caller having proved identity'); + const block = src.slice(i, i + 700); + assert.match(block, /AND device_id = \?/, 'and the lookup is bound to that proven device_id'); + assert.match(src, /const tokenProven = !!\(device_id && validateDeviceToken\(/, + 'proof is a real token check, not merely a device_id being present'); +}); + +test('THE SECURITY RULE: a caller with no credentials never resolves via hardware', () => { + const HW = 'web-shared-model'; + const victim = mkDevice('Customer A screen'); + addFp('web-shared-model-aaaa', victim, HW); + + // A different identical panel arrives with an identity nobody has seen and no credentials. + // Exactly ONE row carries this hardware value — and acting on that would be the takeover. + // One row recorded does not mean one display exists. + assert.equal(resolve('web-shared-model-cccc', HW, null), undefined, + 'an unauthenticated caller is provisioned fresh rather than handed the row'); +}); + +test('and still not, even when several rows share the hardware', () => { + const HW = 'web-shared-model-2'; + addFp('web-shared-model-2-aaaa', mkDevice('A'), HW); + addFp('web-shared-model-2-bbbb', mkDevice('B'), HW); + assert.equal(resolve('web-shared-model-2-cccc', HW, null), undefined); +}); + +test('an AUTHENTICATED player migrates its own row to the new identity', () => { + // The backwards-compatible path: an existing player keeps device_id + token across an update + // and only needs its stored fingerprint moved to the salted form. + const HW = 'web-unique-model'; + const dev = mkDevice('Existing panel'); + addFp('web-unique-model-old', dev, HW); + const got = resolve('web-unique-model-new', HW, dev); + assert.ok(got, 'identity was already proven by token; the hint only locates the row'); + assert.equal(got.device_id, dev, 'and only ever its OWN row'); +}); + +test('an authenticated player cannot migrate someone ELSE\'s row', () => { + const HW = 'web-shared-model-3'; + const theirs = mkDevice('Someone else'); + addFp('web-shared-model-3-theirs', theirs, HW); + const mine = mkDevice('Me'); + assert.equal(resolve('web-shared-model-3-mine', HW, mine), undefined, + 'the lookup is bound to the authenticated device_id'); +}); + +test('an exact identity match never consults the hint', () => { + const dev = mkDevice('Known'); + addFp('web-exact-1234', dev, 'web-exact'); + const got = resolve('web-exact-1234', 'web-totally-different', dev); + assert.equal(got.device_id, dev, 'the identity wins; the hint is only a fallback'); +}); + +test('a legacy row whose stored value IS the bare hardware value still migrates', () => { + // Pre-upgrade rows have fingerprint = the hardware value and hw_fingerprint = NULL. + const HW = 'web-legacy-9z'; + const dev = mkDevice('Legacy panel'); + db.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?,?)').run(HW, dev); + const got = resolve('web-legacy-9z-newsalt', HW, dev); + assert.ok(got, 'the old bare value is found by the hint'); + assert.equal(got.device_id, dev, 'so the existing fleet is not orphaned by this change'); +}); + +test('no hint at all means exact-match-only, exactly as before', () => { + const dev = mkDevice('Old client'); + addFp('apk-hardware-id-42', dev, null); + assert.ok(resolve('apk-hardware-id-42', undefined, dev), 'clients that send no hint are unaffected'); + assert.equal(resolve('apk-unknown-id', undefined, dev), undefined); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 7ad144c..c2093a6 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -402,7 +402,7 @@ module.exports = function setupDeviceSocket(io) { // Device registers with a pairing code (first time) or device_id + device_token (reconnect) socket.on('device:register', (data) => { - const { pairing_code, device_id, device_token, device_info, fingerprint } = data; + const { pairing_code, device_id, device_token, device_info, fingerprint, hw_fingerprint } = data; // #146: resolve identity ONCE via the SNAT-safe chain (device_id -> fingerprint // -> token -> global anon), used by BOTH the operator block and the flap limiter. @@ -460,7 +460,39 @@ module.exports = function setupDeviceSocket(io) { // Track device fingerprint to prevent reinstall abuse if (fingerprint) { try { - const existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint); + let existing = db.prepare('SELECT * FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint); + + // MIGRATION ONLY, and only for a caller that has ALREADY proved who it is. + // + // An existing player keeps its device_id and token across an update, so it authenticates + // by token and merely needs its stored fingerprint moved to the new salted form. That is + // safe: identity was established before we got here, and the hint is only used to find + // the row that identity already belongs to. + // + // A caller WITHOUT credentials must never resolve through the hardware hint, however + // few rows it appears to match. The value describes a MODEL, not a unit — every + // identical panel emits the same one — so "exactly one row" means one row was recorded, + // not that one display exists. Two UniFi Pro Displays at DIFFERENT sites both produced + // web-m73u8w-5f; whichever connected second would have been handed the other's row, + // token and content. Such a caller falls through and is provisioned a new device, which + // costs the operator one pairing code and is the only answer that cannot be wrong. + // + // Clients that send no hw_fingerprint (older players, and the APK/.wgt whose fingerprint + // is a genuinely per-unit hardware id) never enter this branch at all and behave exactly + // as before. + const tokenProven = !!(device_id && validateDeviceToken(device_id, device_token)); + if (!existing && hw_fingerprint && tokenProven) { + const candidates = db.prepare( + 'SELECT * FROM device_fingerprints WHERE (hw_fingerprint = ? OR fingerprint = ?) AND device_id = ?') + .all(hw_fingerprint, hw_fingerprint, device_id); + if (candidates.length === 1) { + const prior = candidates[0].fingerprint; + db.prepare('UPDATE device_fingerprints SET fingerprint = ?, hw_fingerprint = ? WHERE fingerprint = ?') + .run(fingerprint, hw_fingerprint, prior); + existing = { ...candidates[0], fingerprint, hw_fingerprint }; + console.log(`[fingerprint] migrated ${prior} -> per-install identity for authenticated device ${device_id}`); + } + } if (existing) { // device_id arrives from the client and can name a row that no longer exists (a // reconnect after the device was deleted — the same case that emits device:unpaired). @@ -473,8 +505,8 @@ module.exports = function setupDeviceSocket(io) { const known = (id) => !!(id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(id)); const fpDeviceId = known(device_id) ? device_id : (known(existing.device_id) ? existing.device_id : null); - db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ? WHERE fingerprint = ?") - .run(fpDeviceId, fingerprint); + db.prepare("UPDATE device_fingerprints SET last_seen = strftime('%s','now'), device_id = ?, hw_fingerprint = COALESCE(?, hw_fingerprint) WHERE fingerprint = ?") + .run(fpDeviceId, hw_fingerprint || null, fingerprint); // If this fingerprint was previously registered to a different device, block the new registration if (!device_id && existing.device_id && pairing_code) { // Someone reinstalled - link them back to existing device @@ -646,8 +678,8 @@ module.exports = function setupDeviceSocket(io) { // INSERT OR IGNORE does NOT suppress FK violations - so null out an // unknown id instead of letting it throw (was a caught, noisy error). const fpDeviceId = (device_id && db.prepare('SELECT 1 FROM devices WHERE id = ?').get(device_id)) ? device_id : null; - db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)") - .run(fingerprint, fpDeviceId); + db.prepare("INSERT OR IGNORE INTO device_fingerprints (fingerprint, device_id, hw_fingerprint) VALUES (?, ?, ?)") + .run(fingerprint, fpDeviceId, hw_fingerprint || null); } } catch (e) { console.error('Fingerprint tracking error:', e.message); @@ -879,8 +911,8 @@ module.exports = function setupDeviceSocket(io) { // BEFORE the dashboard:device-added emit below so that emit carries restored values. if (fingerprint) { try { - db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen) VALUES (?, ?, strftime('%s','now')) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen") - .run(fingerprint, id); + db.prepare("INSERT INTO device_fingerprints (fingerprint, device_id, last_seen, hw_fingerprint) VALUES (?, ?, strftime('%s','now'), ?) ON CONFLICT(fingerprint) DO UPDATE SET device_id = excluded.device_id, last_seen = excluded.last_seen, hw_fingerprint = COALESCE(excluded.hw_fingerprint, device_fingerprints.hw_fingerprint)") + .run(fingerprint, id, hw_fingerprint || null); const restored = deviceSettings.applyToDevice(id, fingerprint); if (restored) console.log(`[#150] restored saved settings for re-paired device ${id} (fp ${fingerprint.slice(0, 8)}…)`); } catch (e) { console.warn(`[#150] settings restore failed for ${id}: ${e.message}`); }