Browser-based remote control (noVNC) with invite links, per-user access control, garagedoor SSO and a persisted client list. The hub proxies RFB rather than pointing the browser at a VNC server. That is what lets it authenticate upstream with a stored password the browser never sees, and enforce view-only by dropping input messages on the client->server stream instead of hiding buttons. Machines are reachable two ways: direct TCP for LAN hosts, or an outbound agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the agent dependency-free, and node:sqlite keeps the image free of native builds. Ships with an end-to-end suite that boots the real server against a fake VNC server and a fake auth service (72 assertions), plus Gitea Actions CI/CD to Portainer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
82 lines
2.8 KiB
JavaScript
82 lines
2.8 KiB
JavaScript
'use strict';
|
|
|
|
// Secrets at rest: VNC passwords and agent keys are AES-256-GCM encrypted.
|
|
// Tokens that we only ever need to *compare* (invite tokens, agent keys as
|
|
// presented by a client) are stored as SHA-256 and checked in constant time.
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const config = require('./config');
|
|
|
|
const KEY_FILE = path.join(path.dirname(config.dbPath), 'encryption.key');
|
|
|
|
let keyCache = null;
|
|
|
|
// A stable 32-byte key. Prefer ENCRYPTION_KEY from the environment; otherwise
|
|
// generate one next to the database so a bare `npm start` still works and stays
|
|
// decryptable across restarts.
|
|
function key() {
|
|
if (keyCache) return keyCache;
|
|
|
|
let material = config.encryptionKey;
|
|
if (!material) {
|
|
fs.mkdirSync(path.dirname(KEY_FILE), { recursive: true });
|
|
if (fs.existsSync(KEY_FILE)) {
|
|
material = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
|
} else {
|
|
material = crypto.randomBytes(32).toString('hex');
|
|
fs.writeFileSync(KEY_FILE, material, { mode: 0o600 });
|
|
console.warn(`[crypto] ENCRYPTION_KEY not set — generated one at ${KEY_FILE}. Back it up.`);
|
|
}
|
|
}
|
|
|
|
keyCache = crypto.createHash('sha256').update(material, 'utf8').digest();
|
|
return keyCache;
|
|
}
|
|
|
|
function encrypt(plaintext) {
|
|
if (plaintext === null || plaintext === undefined || plaintext === '') return null;
|
|
const iv = crypto.randomBytes(12);
|
|
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
|
const ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
|
|
const tag = cipher.getAuthTag();
|
|
return `v1.${iv.toString('base64')}.${tag.toString('base64')}.${ct.toString('base64')}`;
|
|
}
|
|
|
|
function decrypt(blob) {
|
|
if (!blob) return null;
|
|
const parts = String(blob).split('.');
|
|
if (parts.length !== 4 || parts[0] !== 'v1') return null;
|
|
try {
|
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(parts[1], 'base64'));
|
|
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
|
|
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
|
|
} catch {
|
|
// Wrong key or tampered ciphertext — treat as absent rather than crashing a session.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// URL-safe random token, 32 bytes of entropy.
|
|
function randomToken(bytes = 32) {
|
|
return crypto.randomBytes(bytes).toString('base64url');
|
|
}
|
|
|
|
function sha256(value) {
|
|
return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
|
|
}
|
|
|
|
function timingSafeEqualHex(a, b) {
|
|
const ba = Buffer.from(String(a || ''), 'hex');
|
|
const bb = Buffer.from(String(b || ''), 'hex');
|
|
if (ba.length !== bb.length || ba.length === 0) return false;
|
|
return crypto.timingSafeEqual(ba, bb);
|
|
}
|
|
|
|
function uuid() {
|
|
return crypto.randomUUID();
|
|
}
|
|
|
|
module.exports = { encrypt, decrypt, randomToken, sha256, timingSafeEqualHex, uuid };
|