124 lines
10 KiB
JavaScript
124 lines
10 KiB
JavaScript
const crypto = require("crypto");
|
|
|
|
const DEFAULT_CAPABILITIES = Object.freeze(["transcription.capture.v1", "transcription.settings.v1", "obs.caption.native.v1"]);
|
|
|
|
class DeviceStore {
|
|
constructor(db, options = {}) {
|
|
this.db = db;
|
|
this.now = options.now || Date.now;
|
|
this.randomBytes = options.randomBytes || crypto.randomBytes;
|
|
this.migrate();
|
|
this.cleanup();
|
|
}
|
|
|
|
migrate() {
|
|
this.db.exec(`
|
|
CREATE TABLE IF NOT EXISTS transcription_pairing_tokens (
|
|
id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, lumi_user_id TEXT NOT NULL,
|
|
host TEXT NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, created_at INTEGER NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS transcription_devices (
|
|
id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL,
|
|
credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL,
|
|
first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER,
|
|
pairing_host TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
|
|
`);
|
|
ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT");
|
|
}
|
|
|
|
issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) {
|
|
if (!userId || !host) throw new Error("A Lumi user and host are required.");
|
|
const id = crypto.randomUUID();
|
|
const token = tokenValue(this.randomBytes(32));
|
|
const now = this.now();
|
|
this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)")
|
|
.run(id, digest(token), String(userId), normalizeHost(host), now + ttlMs, now);
|
|
return { pairing_id: id, token, host: normalizeHost(host), expires_at: now + ttlMs, protocol_version: 1 };
|
|
}
|
|
|
|
exchange({ token, device = {} }) {
|
|
const hash = digest(token);
|
|
const row = this.db.prepare("SELECT * FROM transcription_pairing_tokens WHERE token_hash = ?").get(hash);
|
|
if (!row || row.activated_at || row.expires_at < this.now()) {
|
|
const error = new Error(row?.activated_at ? "This pairing package was already activated. Download a new companion package." : "This pairing package is invalid or expired. Download a new companion package.");
|
|
error.code = row?.activated_at ? "PAIRING_ALREADY_USED" : "PAIRING_INVALID";
|
|
throw error;
|
|
}
|
|
const deviceId = crypto.randomUUID();
|
|
const secret = tokenValue(this.randomBytes(32));
|
|
const now = this.now();
|
|
const capabilities = DEFAULT_CAPABILITIES.slice();
|
|
const transaction = this.db.transaction(() => {
|
|
const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id);
|
|
if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; }
|
|
this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at, pairing_host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)")
|
|
.run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now, row.host);
|
|
});
|
|
transaction();
|
|
return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 };
|
|
}
|
|
|
|
authenticate(header, requiredCapability = null) {
|
|
const match = /^LumiDevice\s+([0-9a-f-]{36})\.([A-Za-z0-9_-]{40,})$/i.exec(String(header || ""));
|
|
if (!match) return { allowed: false, reason: "missing_credentials" };
|
|
const row = this.db.prepare("SELECT * FROM transcription_devices WHERE id = ?").get(match[1]);
|
|
if (!row || row.revoked_at || !safeEqual(row.credential_hash, digest(match[2]))) return { allowed: false, reason: row?.revoked_at ? "device_revoked" : "invalid_credentials" };
|
|
const capabilities = parseArray(row.capabilities_json);
|
|
if (requiredCapability && !capabilities.includes(requiredCapability)) return { allowed: false, reason: "capability_revoked" };
|
|
this.db.prepare("UPDATE transcription_devices SET last_connected_at = ? WHERE id = ?").run(this.now(), row.id);
|
|
return { allowed: true, device: serialize(row, capabilities) };
|
|
}
|
|
|
|
list(options = {}) {
|
|
const status = options.status === "revoked" ? "revoked" : options.status === "all" ? "all" : "active";
|
|
const where = status === "revoked" ? "WHERE revoked_at IS NOT NULL" : status === "active" ? "WHERE revoked_at IS NULL" : "";
|
|
return this.db.prepare(`SELECT * FROM transcription_devices ${where} ORDER BY last_connected_at DESC`).all().map((row) => serialize(row, parseArray(row.capabilities_json)));
|
|
}
|
|
revoke(deviceId) { return this.db.prepare("UPDATE transcription_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.now(), deviceId).changes === 1; }
|
|
cleanup(now = this.now()) { return this.db.prepare("DELETE FROM transcription_devices WHERE revoked_at IS NOT NULL AND revoked_at <= ?").run(now - 30 * 86400000).changes; }
|
|
setCapabilities(deviceId, capabilities) {
|
|
const allowed = DEFAULT_CAPABILITIES.filter((capability) => new Set(capabilities || []).has(capability));
|
|
const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes;
|
|
return changed ? allowed : null;
|
|
}
|
|
updateRuntime(deviceId, input = {}) {
|
|
const row = this.db.prepare("SELECT metadata_json FROM transcription_devices WHERE id = ? AND revoked_at IS NULL").get(deviceId);
|
|
if (!row) return false;
|
|
let metadata = {};
|
|
try { metadata = JSON.parse(row.metadata_json || "{}"); } catch { }
|
|
if (typeof input.bridge_installed === "boolean") metadata.bridge_installed = input.bridge_installed;
|
|
if (typeof input.bridge_connected === "boolean") metadata.bridge_connected = input.bridge_connected;
|
|
if (input.bridge_version !== undefined) metadata.bridge_version = clean(input.bridge_version, 32) || null;
|
|
if (typeof input.path_test_valid === "boolean") metadata.path_test_valid = input.path_test_valid;
|
|
if (Number.isFinite(Number(input.path_test_at))) metadata.path_test_at = Math.max(0, Number(input.path_test_at));
|
|
if (input.companion_version !== undefined) metadata.companion_version = clean(input.companion_version, 32);
|
|
if (input.companion_plugin_version !== undefined) metadata.companion_plugin_version = clean(input.companion_plugin_version, 32);
|
|
metadata.runtime_seen_at = this.now();
|
|
return this.db.prepare("UPDATE transcription_devices SET metadata_json = ?, last_connected_at = ? WHERE id = ? AND revoked_at IS NULL")
|
|
.run(JSON.stringify(metadata), this.now(), deviceId).changes === 1;
|
|
}
|
|
pairingAllowsHttp(token, requestOrigin) {
|
|
const row = this.db.prepare("SELECT host, activated_at, expires_at FROM transcription_pairing_tokens WHERE token_hash = ?").get(digest(token));
|
|
return Boolean(row && !row.activated_at && row.expires_at >= this.now() && sameLoopbackOrigin(row.host, requestOrigin));
|
|
}
|
|
}
|
|
|
|
function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); }
|
|
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
|
function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); }
|
|
function normalizeHost(value) { const url = new URL(String(value)); if (url.protocol !== "https:" && !isLoopbackHttpOrigin(url)) throw new Error("Lumi Companion requires HTTPS. HTTP is allowed only when the pairing URL explicitly uses localhost or a loopback address."); return url.origin; }
|
|
function isLoopbackOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return ["http:", "https:"].includes(url.protocol) && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } }
|
|
function isLoopbackHttpOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return url.protocol === "http:" && isLoopbackOrigin(url); } catch { return false; } }
|
|
function sameLocalhostOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackOrigin(a) && isLoopbackOrigin(b) && a.origin === b.origin; } catch { return false; } }
|
|
function sameLoopbackOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackHttpOrigin(a) && isLoopbackHttpOrigin(b) && a.origin === b.origin; } catch { return false; } }
|
|
function insecureDeviceAllowed(device, requestOrigin, remoteAddress) { return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(String(remoteAddress || "")) && sameLoopbackOrigin(device?.pairing_host, requestOrigin); }
|
|
function ensureColumn(db, table, column, type) { if (!db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); }
|
|
function clean(value, max) { return String(value || "").trim().slice(0, max); }
|
|
function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } }
|
|
function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; }
|
|
function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at, pairing_host: row.pairing_host || null }; }
|
|
|
|
module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest, normalizeHost, isLoopbackOrigin, isLoopbackHttpOrigin, sameLocalhostOrigin, sameLoopbackOrigin, insecureDeviceAllowed };
|