#!/usr/bin/env node /** * Device Fingerprinting via RF Emissions — Multi-Frequency Mesh Application * * Identifies electronic devices by their unique RF characteristics across * multiple WiFi channels. Each device creates distinctive subcarrier patterns: * * - WiFi APs: unique transmit power, phase noise, clock drift * - Printers: motor EMI creates specific subcarrier modulation * - Microwaves: 2.45 GHz magnetron radiates across channels 8-11 * - Bluetooth: frequency-hopping creates transient spikes * * Correlates WiFi scan SSID/signal with CSI patterns to build per-device * fingerprints, then detects when devices become active or inactive. * * Requires multi-frequency mesh scanning (ADR-073): 2 ESP32 nodes hopping * across channels 1, 3, 5, 6, 9, 11. * * Usage: * node scripts/device-fingerprint.js * node scripts/device-fingerprint.js --port 5006 --duration 120 * node scripts/device-fingerprint.js --replay data/recordings/overnight-1775217646.csi.jsonl * node scripts/device-fingerprint.js --learn 30 * * ADR: docs/adr/ADR-078-multifreq-mesh-applications.md */ 'use strict'; const dgram = require('dgram'); const fs = require('fs'); const readline = require('readline'); const { parseArgs } = require('util'); // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const { values: args } = parseArgs({ options: { port: { type: 'string', short: 'p', default: '5006' }, duration: { type: 'string', short: 'd' }, replay: { type: 'string', short: 'r' }, interval: { type: 'string', short: 'i', default: '5000' }, learn: { type: 'string', short: 'l', default: '20' }, json: { type: 'boolean', default: false }, 'save-fingerprints': { type: 'string' }, 'load-fingerprints': { type: 'string' }, }, strict: true, }); const PORT = parseInt(args.port, 10); const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null; const INTERVAL_MS = parseInt(args.interval, 10); const LEARN_DURATION = parseInt(args.learn, 10); const JSON_OUTPUT = args.json; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const CSI_MAGIC = 0xC5110001; const HEADER_SIZE = 20; const CHANNEL_FREQ = {}; for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5; const NODE1_CHANNELS = [1, 6, 11]; const NODE2_CHANNELS = [3, 5, 9]; // Known devices from WiFi scan (these are the devices we can fingerprint) const KNOWN_DEVICES = [ { id: 'ruv-net', ssid: 'ruv.net', channel: 5, signal: 100, type: 'router' }, { id: 'cohen-guest', ssid: 'Cohen-Guest', channel: 5, signal: 100, type: 'router' }, { id: 'cogeco-21b20', ssid: 'COGECO-21B20', channel: 11, signal: 100, type: 'router' }, { id: 'hp-printer', ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5, signal: 94, type: 'printer' }, { id: 'conclusion', ssid: 'conclusion mesh', channel: 3, signal: 44, type: 'mesh-node' }, { id: 'netgear72', ssid: 'NETGEAR72', channel: 9, signal: 42, type: 'router' }, { id: 'cogeco-4321', ssid: 'COGECO-4321', channel: 11, signal: 30, type: 'router' }, { id: 'innanen', ssid: 'Innanen', channel: 6, signal: 19, type: 'router' }, ]; // Activity states const ACTIVITY = { UNKNOWN: 'unknown', ACTIVE: 'active', IDLE: 'idle', CHANGED: 'changed', }; // --------------------------------------------------------------------------- // Device fingerprint // --------------------------------------------------------------------------- class DeviceFingerprint { constructor(device) { this.device = device; this.id = device.id; this.channel = device.channel; // Per-subcarrier signature (learned during training) this.baselineMean = null; // Float64Array this.baselineStd = null; // Float64Array this.varianceProfile = null; // Float64Array - characteristic variance pattern this.nSub = 0; this.trainCount = 0; // Welford accumulators for training this._sum = null; this._sumSq = null; this._varSum = null; this._varSumSq = null; this._frameAmps = []; // store recent frames for variance computation // Runtime state this.activity = ACTIVITY.UNKNOWN; this.lastScore = 0; this.lastSeen = 0; this.activityHistory = []; this.maxHistory = 30; } /** Ingest a training frame */ train(amplitudes) { const n = amplitudes.length; if (!this._sum) { this.nSub = n; this._sum = new Float64Array(n); this._sumSq = new Float64Array(n); } this.trainCount++; for (let i = 0; i < n && i < this.nSub; i++) { this._sum[i] += amplitudes[i]; this._sumSq[i] += amplitudes[i] * amplitudes[i]; } // Keep last 10 frames for variance profile this._frameAmps.push(new Float64Array(amplitudes)); if (this._frameAmps.length > 10) this._frameAmps.shift(); } /** Finalize training */ finalizeTrain() { if (this.trainCount < 3 || !this._sum) return false; this.baselineMean = new Float64Array(this.nSub); this.baselineStd = new Float64Array(this.nSub); for (let i = 0; i < this.nSub; i++) { this.baselineMean[i] = this._sum[i] / this.trainCount; const variance = (this._sumSq[i] / this.trainCount) - (this.baselineMean[i] ** 2); this.baselineStd[i] = Math.sqrt(Math.max(0, variance)); if (this.baselineStd[i] < 0.1) this.baselineStd[i] = 0.1; } // Compute variance profile from stored frames if (this._frameAmps.length >= 3) { this.varianceProfile = new Float64Array(this.nSub); for (let i = 0; i < this.nSub; i++) { let sum = 0, sumSq = 0; for (const frame of this._frameAmps) { sum += frame[i]; sumSq += frame[i] * frame[i]; } const n = this._frameAmps.length; const mean = sum / n; this.varianceProfile[i] = (sumSq / n) - (mean * mean); } } // Clean up training data this._sum = null; this._sumSq = null; this._frameAmps = []; return true; } /** * Score a new frame against this device's fingerprint. * Returns a similarity score (0 = no match, 1 = perfect match). */ score(amplitudes) { if (!this.baselineMean) return 0; const n = Math.min(amplitudes.length, this.nSub); let matchScore = 0; let count = 0; for (let i = 0; i < n; i++) { // Normalized difference from baseline const diff = Math.abs(amplitudes[i] - this.baselineMean[i]); const normalizedDiff = diff / this.baselineStd[i]; // Score: 1.0 if within 1 std, decreasing beyond const subScore = Math.exp(-0.5 * normalizedDiff * normalizedDiff); matchScore += subScore; count++; } return count > 0 ? matchScore / count : 0; } /** * Detect activity change. * Compare current frame's variance against baseline variance profile. */ detectActivity(amplitudes, timestamp) { const similarity = this.score(amplitudes); this.lastScore = similarity; this.lastSeen = timestamp; // Activity thresholds const prevActivity = this.activity; if (similarity > 0.7) { this.activity = ACTIVITY.ACTIVE; } else if (similarity > 0.4) { this.activity = ACTIVITY.CHANGED; } else { this.activity = ACTIVITY.IDLE; } // Record transitions if (prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN) { this.activityHistory.push({ timestamp, from: prevActivity, to: this.activity, score: similarity.toFixed(3), }); if (this.activityHistory.length > this.maxHistory) this.activityHistory.shift(); } return { id: this.id, ssid: this.device.ssid, type: this.device.type, channel: this.channel, activity: this.activity, similarity: similarity.toFixed(3), changed: prevActivity !== this.activity && prevActivity !== ACTIVITY.UNKNOWN, }; } /** Export fingerprint for persistence */ exportFingerprint() { return { id: this.id, device: this.device, nSub: this.nSub, trainCount: this.trainCount, baselineMean: this.baselineMean ? Array.from(this.baselineMean) : null, baselineStd: this.baselineStd ? Array.from(this.baselineStd) : null, varianceProfile: this.varianceProfile ? Array.from(this.varianceProfile) : null, }; } /** Import fingerprint from saved data */ importFingerprint(data) { this.nSub = data.nSub; this.trainCount = data.trainCount; this.baselineMean = data.baselineMean ? new Float64Array(data.baselineMean) : null; this.baselineStd = data.baselineStd ? new Float64Array(data.baselineStd) : null; this.varianceProfile = data.varianceProfile ? new Float64Array(data.varianceProfile) : null; } } // --------------------------------------------------------------------------- // Device fingerprint manager // --------------------------------------------------------------------------- class FingerprintManager { constructor(learnDuration) { this.learnDuration = learnDuration; this.fingerprints = new Map(); // id -> DeviceFingerprint this.learning = true; this.startTime = null; this.totalFrames = 0; // Initialize fingerprints for known devices for (const device of KNOWN_DEVICES) { this.fingerprints.set(device.id, new DeviceFingerprint(device)); } } ingestFrame(channel, amplitudes, timestamp) { this.totalFrames++; if (!this.startTime) this.startTime = timestamp; // Learning phase: train fingerprints for devices on this channel if (this.learning) { for (const fp of this.fingerprints.values()) { if (fp.channel === channel) { fp.train(amplitudes); } } if (timestamp - this.startTime >= this.learnDuration) { // Finalize all fingerprints let trained = 0; for (const fp of this.fingerprints.values()) { if (fp.finalizeTrain()) trained++; } this.learning = false; return { event: 'learn_complete', trained, total: this.fingerprints.size }; } return { event: 'learning', elapsed: timestamp - this.startTime, duration: this.learnDuration }; } // Detection phase: score all devices on this channel const results = []; for (const fp of this.fingerprints.values()) { if (fp.channel === channel) { const result = fp.detectActivity(amplitudes, timestamp); results.push(result); } } return { event: 'detect', results }; } /** Get current device activity summary */ getSummary() { const devices = []; for (const fp of this.fingerprints.values()) { devices.push({ id: fp.id, ssid: fp.device.ssid, type: fp.device.type, channel: fp.channel, activity: fp.activity, similarity: fp.lastScore.toFixed(3), trained: fp.baselineMean !== null, trainFrames: fp.trainCount, transitions: fp.activityHistory.length, }); } return { learning: this.learning, totalFrames: this.totalFrames, devices: devices.sort((a, b) => parseFloat(b.similarity) - parseFloat(a.similarity)), }; } /** Save fingerprints to file */ saveFingerprints(filePath) { const data = {}; for (const [id, fp] of this.fingerprints) { if (fp.baselineMean) { data[id] = fp.exportFingerprint(); } } fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); return Object.keys(data).length; } /** Load fingerprints from file */ loadFingerprints(filePath) { if (!fs.existsSync(filePath)) return 0; const data = JSON.parse(fs.readFileSync(filePath, 'utf8')); let loaded = 0; for (const [id, fpData] of Object.entries(data)) { if (this.fingerprints.has(id)) { this.fingerprints.get(id).importFingerprint(fpData); loaded++; } } if (loaded > 0) this.learning = false; return loaded; } } // --------------------------------------------------------------------------- // CSI parsing // --------------------------------------------------------------------------- function parseIqHex(iqHex, nSubcarriers) { const bytes = Buffer.from(iqHex, 'hex'); const amplitudes = new Float64Array(nSubcarriers); for (let sc = 0; sc < nSubcarriers; sc++) { const offset = 2 + sc * 2; if (offset + 1 >= bytes.length) break; let I = bytes[offset]; let Q = bytes[offset + 1]; if (I > 127) I -= 256; if (Q > 127) Q -= 256; amplitudes[sc] = Math.sqrt(I * I + Q * Q); } return amplitudes; } function parseCSIFrame(buf) { if (buf.length < HEADER_SIZE) return null; const magic = buf.readUInt32LE(0); if (magic !== CSI_MAGIC) return null; const nodeId = buf.readUInt8(4); const nSubcarriers = buf.readUInt16LE(6); const freqMhz = buf.readUInt32LE(8); const amplitudes = new Float64Array(nSubcarriers); for (let sc = 0; sc < nSubcarriers; sc++) { const offset = HEADER_SIZE + sc * 2; if (offset + 1 >= buf.length) break; const I = buf.readInt8(offset); const Q = buf.readInt8(offset + 1); amplitudes[sc] = Math.sqrt(I * I + Q * Q); } let channel = 0; if (freqMhz >= 2412 && freqMhz <= 2484) { channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1; } return { nodeId, nSubcarriers, freqMhz, amplitudes, channel }; } const nodeChannelIdx = { 1: 0, 2: 0 }; function assignChannel(nodeId) { const channels = nodeId === 1 ? NODE1_CHANNELS : NODE2_CHANNELS; const ch = channels[nodeChannelIdx[nodeId] % channels.length]; nodeChannelIdx[nodeId]++; return ch; } // --------------------------------------------------------------------------- // Visualization // --------------------------------------------------------------------------- function renderDeviceTable(manager) { const summary = manager.getSummary(); const lines = []; lines.push(''); lines.push(' DEVICE FINGERPRINTING — RF EMISSIONS ANALYSIS'); lines.push(' ' + '='.repeat(60)); lines.push(''); if (summary.learning) { const elapsed = manager.startTime ? Date.now() / 1000 - manager.startTime : 0; const progress = Math.min(100, (elapsed / manager.learnDuration) * 100); const barLen = Math.floor(progress / 2); const bar = '\u2588'.repeat(barLen) + '\u2591'.repeat(50 - barLen); lines.push(` Learning device signatures: [${bar}] ${progress.toFixed(0)}%`); lines.push(` Frames: ${summary.totalFrames}`); lines.push(''); } // Device activity table const activitySymbol = { [ACTIVITY.ACTIVE]: '[ON] ', [ACTIVITY.IDLE]: '[off]', [ACTIVITY.CHANGED]: '[CHG]', [ACTIVITY.UNKNOWN]: '[ ? ]', }; lines.push(' Device Type Ch Similarity Status'); lines.push(' ' + '-'.repeat(65)); for (const dev of summary.devices) { const status = activitySymbol[dev.activity] || '[ ? ]'; const trained = dev.trained ? '' : ' (untrained)'; lines.push( ` ${dev.ssid.substring(0, 28).padEnd(30)} ${dev.type.padEnd(10)} ${String(dev.channel).padStart(2)} ` + `${dev.similarity.padStart(7)} ${status}${trained}` ); } return lines.join('\n'); } function renderTimeline(manager) { const summary = manager.getSummary(); const lines = []; lines.push(''); lines.push(' Activity Transitions:'); lines.push(' ' + '-'.repeat(50)); let hasTransitions = false; for (const dev of summary.devices) { const fp = manager.fingerprints.get(dev.id); if (fp && fp.activityHistory.length > 0) { hasTransitions = true; const recent = fp.activityHistory.slice(-3); for (const t of recent) { const time = new Date(t.timestamp * 1000).toISOString().substring(11, 19); lines.push(` ${time} ${dev.ssid.substring(0, 20).padEnd(20)} ${t.from} -> ${t.to} (score=${t.score})`); } } } if (!hasTransitions) { lines.push(' (no transitions detected yet)'); } return lines.join('\n'); } function renderChannelActivity(manager) { const summary = manager.getSummary(); const lines = []; lines.push(''); lines.push(' Per-Channel Device Activity:'); const channels = [...new Set(summary.devices.map(d => d.channel))].sort((a, b) => a - b); for (const ch of channels) { const devs = summary.devices.filter(d => d.channel === ch); const activeCount = devs.filter(d => d.activity === ACTIVITY.ACTIVE).length; lines.push(` ch${ch} (${CHANNEL_FREQ[ch]} MHz): ${activeCount}/${devs.length} devices active`); for (const dev of devs) { const bar = '\u2588'.repeat(Math.floor(parseFloat(dev.similarity) * 20)); lines.push(` ${dev.ssid.substring(0, 18).padEnd(18)} ${bar} ${dev.similarity}`); } } return lines.join('\n'); } // --------------------------------------------------------------------------- // Global state // --------------------------------------------------------------------------- const manager = new FingerprintManager(LEARN_DURATION); let lastDisplayMs = 0; // Load saved fingerprints if specified if (args['load-fingerprints']) { const loaded = manager.loadFingerprints(args['load-fingerprints']); if (!JSON_OUTPUT) console.log(`Loaded ${loaded} fingerprints from ${args['load-fingerprints']}`); } function displayUpdate() { if (JSON_OUTPUT) { const summary = manager.getSummary(); console.log(JSON.stringify({ timestamp: Date.now() / 1000, learning: summary.learning, totalFrames: summary.totalFrames, devices: summary.devices.map(d => ({ id: d.id, ssid: d.ssid, activity: d.activity, similarity: d.similarity, channel: d.channel, })), })); } else { process.stdout.write('\x1B[2J\x1B[H'); console.log(renderDeviceTable(manager)); console.log(renderTimeline(manager)); console.log(renderChannelActivity(manager)); console.log(''); console.log(` Total frames: ${manager.totalFrames}`); console.log(' Press Ctrl+C to exit'); } } // --------------------------------------------------------------------------- // Live mode // --------------------------------------------------------------------------- function startLive() { const sock = dgram.createSocket('udp4'); sock.on('message', (buf) => { if (buf.length < 4) return; const magic = buf.readUInt32LE(0); if (magic !== CSI_MAGIC) return; const frame = parseCSIFrame(buf); if (!frame) return; const result = manager.ingestFrame(frame.channel, frame.amplitudes, Date.now() / 1000); // Announce learning completion if (result && result.event === 'learn_complete' && !JSON_OUTPUT) { console.log(`\nLearning complete! Trained ${result.trained}/${result.total} device fingerprints`); } const now = Date.now(); if (now - lastDisplayMs >= INTERVAL_MS) { displayUpdate(); lastDisplayMs = now; } }); sock.bind(PORT, () => { if (!JSON_OUTPUT) { console.log(`Device Fingerprinter listening on UDP port ${PORT}`); console.log(`Learning duration: ${LEARN_DURATION}s`); console.log(`Known devices: ${KNOWN_DEVICES.length}`); console.log('Waiting for CSI frames...'); } }); if (DURATION_MS) { setTimeout(() => { displayUpdate(); if (args['save-fingerprints']) { const saved = manager.saveFingerprints(args['save-fingerprints']); if (!JSON_OUTPUT) console.log(`Saved ${saved} fingerprints to ${args['save-fingerprints']}`); } sock.close(); process.exit(0); }, DURATION_MS); } } // --------------------------------------------------------------------------- // Replay mode // --------------------------------------------------------------------------- async function startReplay(filePath) { if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); process.exit(1); } const rl = readline.createInterface({ input: fs.createReadStream(filePath), crlfDelay: Infinity, }); let frameCount = 0; let lastAnalysisTs = 0; let windowCount = 0; let learnComplete = false; for await (const line of rl) { if (!line.trim()) continue; let record; try { record = JSON.parse(line); } catch { continue; } if (record.type !== 'raw_csi' || !record.iq_hex) continue; const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64); const channel = record.channel || assignChannel(record.node_id); const result = manager.ingestFrame(channel, amplitudes, record.timestamp); frameCount++; if (result && result.event === 'learn_complete' && !learnComplete) { learnComplete = true; if (!JSON_OUTPUT) { console.log(`\nLearning complete at t=${record.timestamp.toFixed(1)}s`); console.log(`Trained ${result.trained}/${result.total} device fingerprints`); console.log(''); } } const tsMs = record.timestamp * 1000; if (lastAnalysisTs === 0) lastAnalysisTs = tsMs; if (tsMs - lastAnalysisTs >= INTERVAL_MS) { windowCount++; const summary = manager.getSummary(); if (JSON_OUTPUT) { console.log(JSON.stringify({ window: windowCount, timestamp: record.timestamp, learning: summary.learning, devices: summary.devices.map(d => ({ id: d.id, activity: d.activity, similarity: d.similarity, })), })); } else if (!summary.learning) { // Compact per-window output const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE); const changed = summary.devices.filter(d => d.activity === ACTIVITY.CHANGED); let line = ` [${String(windowCount).padStart(4)}] t=${record.timestamp.toFixed(1)}s active: `; line += active.length > 0 ? active.map(d => `${d.ssid.substring(0, 15)}(${d.similarity})`).join(', ') : '(none)'; if (changed.length > 0) { line += ' changed: ' + changed.map(d => d.ssid.substring(0, 12)).join(', '); } console.log(line); } lastAnalysisTs = tsMs; } } // Save fingerprints if requested if (args['save-fingerprints']) { const saved = manager.saveFingerprints(args['save-fingerprints']); if (!JSON_OUTPUT) console.log(`\nSaved ${saved} fingerprints to ${args['save-fingerprints']}`); } // Final summary if (!JSON_OUTPUT) { const summary = manager.getSummary(); console.log(''); console.log('='.repeat(60)); console.log('DEVICE FINGERPRINT SUMMARY'); console.log('='.repeat(60)); console.log(renderDeviceTable(manager)); console.log(renderTimeline(manager)); // Statistics const trained = summary.devices.filter(d => d.trained).length; const active = summary.devices.filter(d => d.activity === ACTIVITY.ACTIVE).length; console.log(''); console.log(` Trained fingerprints: ${trained}/${summary.devices.length}`); console.log(` Currently active: ${active}/${summary.devices.length}`); console.log(` Total frames: ${frameCount}`); console.log(` Analysis windows: ${windowCount}`); } } // --------------------------------------------------------------------------- // Entry point // --------------------------------------------------------------------------- if (args.replay) { startReplay(args.replay); } else { startLive(); }