Lumi/plugins/lumi_transcription/backend/companion/device_store.js
2026-07-22 11:01:49 +02:00

91 lines
6.7 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.allowInsecure = options.allowInsecure === true;
this.migrate();
}
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
);
CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id);
`);
}
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, this.allowInsecure), now + ttlMs, now);
return { pairing_id: id, token, host: normalizeHost(host, this.allowInsecure), 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) 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);
});
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() { return this.db.prepare("SELECT * FROM transcription_devices 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; }
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;
}
}
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, allowInsecure = false) { const url = new URL(String(value)); if (url.protocol !== "https:" && !(allowInsecure && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname))) throw new Error("Lumi Companion requires HTTPS. Insecure HTTP is allowed only for explicit localhost development."); return url.origin; }
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 }; }
module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest };