fix(console): validate deploy-string public key and fall back to Go (#340)

Reject placeholder/invalid id_ed25519.pub values in Dashboard client config, prefer live Go /api/server-key, and set PUB_KEY_PATH in Windows NSSM console env.
This commit is contained in:
UNITRONIX
2026-08-01 09:17:44 +02:00
parent c17521b3e6
commit 62752125df
10 changed files with 257 additions and 68 deletions
+3
View File
@@ -1,5 +1,8 @@
## [Unreleased]
### Fixed
- **Dashboard Copy deploy string with invalid/placeholder public key (#340):** client config / deploy string / QR now require a valid Ed25519 key (base64 → 32 bytes), reject placeholders, and fall back to the live Go `GET /api/server-key` when `id_ed25519.pub` is missing or bad. Windows installer also sets `PUB_KEY_PATH` in the console NSSM environment. Ships via panel update (re-run `betterdesk.ps1` service setup to refresh NSSM env on Windows).
### Changed
- _(none yet)_
+1
View File
@@ -2089,6 +2089,7 @@ function Setup-Services {
"KEYS_PATH=$script:RUSTDESK_PATH",
"DATA_DIR=$script:CONSOLE_PATH\data",
"DB_PATH=$script:RUSTDESK_PATH\db_v2.sqlite3",
"PUB_KEY_PATH=$script:RUSTDESK_PATH\id_ed25519.pub",
"API_KEY_PATH=$script:RUSTDESK_PATH\.api_key",
"HBBS_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api",
"BETTERDESK_API_URL=${apiScheme}://localhost:$($script:API_PORT)/api",
+4 -4
View File
@@ -32,9 +32,9 @@ router.get('/api/stats', requireAuth, async (req, res) => {
// Get server health
const hbbsHealth = await serverBackend.getHealth();
// Get public key info
const publicKey = keyService.getPublicKey();
// Get public key info (file or live Go key)
const publicKey = await keyService.resolvePublicKey();
res.json({
success: true,
data: {
@@ -148,7 +148,7 @@ router.get('/api/dashboard/client-config', requireAuth, async (req, res) => {
try {
const queryHost = typeof req.query.host === 'string' ? req.query.host : '';
const endpoints = clientConfigHost.resolveRustDeskEndpoints(req, queryHost);
const clientConfig = keyService.getClientConfig(endpoints);
const clientConfig = await keyService.getClientConfig(endpoints);
const qr = await keyService.getServerConfigQR(endpoints);
res.json({
+14 -9
View File
@@ -112,8 +112,13 @@ function finalizeBundleBrandingSync(input) {
* installation registers on its own and receives a unique device_token
* after operator approval (managed enrollment).
*/
function finalizeBundleBranding(input) {
async function finalizeBundleBranding(input) {
const branding = finalizeBundleBrandingSync(input);
const pubKey = (await keyService.resolvePublicKey()) || '';
if (branding.server) {
branding.server.public_key = pubKey;
}
branding.server_key = pubKey;
// Strip legacy shared tokens from older bundles on save/rebuild.
delete branding.enrollment_token;
delete branding.has_enrollment_token;
@@ -189,14 +194,14 @@ router.get('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async
}
});
router.get('/api/generator/defaults', requireAuth, requireAdmin, (req, res) => {
router.get('/api/generator/defaults', requireAuth, requireAdmin, async (req, res) => {
res.json({
success: true,
data: {
server_host: conn.defaultServerHost(),
use_https: conn.defaultUseHttps(),
api_port: conn.defaultApiPort(),
public_key: keyService.getPublicKey() || '',
public_key: (await keyService.resolvePublicKey()) || '',
},
});
});
@@ -231,7 +236,7 @@ router.post('/api/generator/bundles', requireAuth, requireAdmin, async (req, res
}
const normalized = productType === 'rdclient'
? { ...base, bundle_id: bundleId, server_url: base.panel_url }
: finalizeBundleBranding(base);
: await finalizeBundleBranding(base);
if (productType !== 'rdclient') {
normalized.bundle_id = bundleId;
normalized.product_name = productType === 'agent-client'
@@ -271,7 +276,7 @@ router.put('/api/generator/bundles/:bundleId', requireAuth, requireAdmin, async
if (!valid) {
return res.status(400).json({ success: false, error: req.t('generator.errors.validation_failed'), errors, details: errors });
}
const normalized = finalizeBundleBranding(base);
const normalized = await finalizeBundleBranding(base);
normalized.bundle_id = req.params.bundleId;
normalized.product_name = normalizeProductType(existing.product_type) === 'agent-client'
? (normalized.company_name ? `${normalized.company_name} Agent` : 'BetterDesk Agent')
@@ -511,9 +516,9 @@ router.get('/api/d/:publicId/download/:platform/:arch/:format', async (req, res)
// Legacy TOML config generator (deprecated, kept for compatibility)
// =========================================================================
router.get('/api/generator/config', requireAuth, (req, res) => {
router.get('/api/generator/config', requireAuth, async (req, res) => {
try {
const publicKey = keyService.getPublicKey();
const publicKey = await keyService.resolvePublicKey();
res.json({
success: true,
data: {
@@ -528,13 +533,13 @@ router.get('/api/generator/config', requireAuth, (req, res) => {
}
});
router.post('/api/generator/generate-config', requireAuth, (req, res) => {
router.post('/api/generator/generate-config', requireAuth, async (req, res) => {
try {
const { serverHost, serverPort, relayHost, relayPort, clientName } = req.body;
if (!serverHost) {
return res.status(400).json({ success: false, error: 'Server host is required' });
}
const publicKey = keyService.getPublicKey();
const publicKey = await keyService.resolvePublicKey();
const lines = [];
lines.push(`rendezvous_server = ${serverHost}:${serverPort || 21116}`);
if (relayHost) lines.push(`relay_server = ${relayHost}:${relayPort || 21117}`);
+8 -8
View File
@@ -39,17 +39,17 @@ router.get('/keys', requireAuth, (req, res) => {
/**
* GET /api/keys/public - Get public key
*/
router.get('/api/keys/public', requireAuth, (req, res) => {
router.get('/api/keys/public', requireAuth, async (req, res) => {
try {
const publicKey = keyService.getPublicKey();
const publicKey = await keyService.resolvePublicKey();
if (!publicKey) {
return res.status(404).json({
success: false,
error: req.t('keys.not_found')
});
}
res.json({
success: true,
data: {
@@ -100,17 +100,17 @@ router.get('/api/keys/public/qr', requireAuth, async (req, res) => {
/**
* GET /api/keys/public/download - Download public key file
*/
router.get('/api/keys/public/download', requireAuth, (req, res) => {
router.get('/api/keys/public/download', requireAuth, async (req, res) => {
try {
const publicKey = keyService.getPublicKey();
const publicKey = await keyService.resolvePublicKey();
if (!publicKey) {
return res.status(404).json({
success: false,
error: req.t('keys.not_found')
});
}
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', 'attachment; filename="id_ed25519.pub"');
res.send(publicKey);
+7 -9
View File
@@ -24,7 +24,6 @@
const express = require('express');
const router = express.Router();
const crypto = require('crypto');
const fs = require('fs');
const db = require('../services/database');
const config = require('../config/config');
const { requirePermission } = require('../middleware/auth');
@@ -57,13 +56,12 @@ function getClientIp(req) {
}
/**
* Read the server public key from disk (base64).
* Read the server public key (validated file, then live Go key).
*/
function getServerPublicKey() {
async function getServerPublicKey() {
try {
if (fs.existsSync(config.pubKeyPath)) {
return fs.readFileSync(config.pubKeyPath, 'utf8').trim();
}
const keyService = require('../services/keyService');
return (await keyService.resolvePublicKey()) || '';
} catch (_) { /* ignore */ }
return '';
}
@@ -78,14 +76,14 @@ function generateDeviceAccessToken() {
/**
* Build the server config payload returned to devices upon approval.
*/
function buildServerConfig() {
async function buildServerConfig() {
const protocol = config.httpsEnabled ? 'https' : 'http';
const consoleUrl = `${protocol}://0.0.0.0:${config.port}`;
return {
console_url: consoleUrl,
server_address: `0.0.0.0:21116`,
server_key: getServerPublicKey(),
server_key: await getServerPublicKey(),
access_token: generateDeviceAccessToken(),
};
}
@@ -283,7 +281,7 @@ router.put('/api/registrations/:id/approve', requirePermission('enrollment.appro
}
// Build server config — use actual server address from the request
const serverConfig = buildServerConfig();
const serverConfig = await buildServerConfig();
// Replace 0.0.0.0 with the actual hostname / IP the admin is accessing
const actualHost = req.headers.host?.split(':')[0] || req.hostname || 'localhost';
+6 -6
View File
@@ -78,13 +78,13 @@ function getRemoteRelay() {
try { return require('../services/remoteRelay'); } catch { return null; }
}
// Read server public key on each viewer render (Go may write id_ed25519.pub after
// console start; avoid caching empty/stale key across the process lifetime).
function getServerPubKey() {
// Resolve server public key on each viewer render (file may be stale/wrong;
// fall back to live Go /api/server-key — issue #340).
async function resolveServerPubKey() {
try {
return keyService.getPublicKey() || '';
return (await keyService.resolvePublicKey()) || '';
} catch (err) {
console.warn('Warning: Could not read server public key:', err.message);
console.warn('Warning: Could not resolve server public key:', err.message);
return '';
}
}
@@ -233,7 +233,7 @@ router.get('/remote/:deviceId', rdClientPageLimiter, requireRemoteAccess, async
activePage: 'remote',
deviceId: deviceId,
device: device || { id: deviceId, hostname: '', platform: '', note: '' },
serverPubKey: getServerPubKey(),
serverPubKey: await resolveServerPubKey(),
capabilities,
guestToken: req.guestToken || getGuestTokenFromQuery(req) || '',
layout: 'viewer'
+8 -15
View File
@@ -1596,19 +1596,11 @@ function cleanupTfaSessions(sessions) {
* This key is used by clients to verify peer identity (signed_id_pk).
* Public key is inherently safe to expose — no auth required.
*/
router.get('/api/server-key', (req, res) => {
router.get('/api/server-key', async (req, res) => {
try {
if (!fs.existsSync(config.pubKeyPath)) {
return res.json({ key: '' });
}
const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim();
// Validate: should decode to 32 bytes (Ed25519 public key)
const decoded = Buffer.from(key, 'base64');
if (decoded.length !== 32) {
console.warn('[API:SERVER-KEY] Invalid RS public key length:', decoded.length);
return res.json({ key: '' });
}
return res.json({ key });
const keyService = require('../services/keyService');
const key = await keyService.resolvePublicKey();
return res.json({ key: key || '' });
} catch (err) {
console.warn('[API:SERVER-KEY] Error reading public key:', err.message);
return res.json({ key: '' });
@@ -1619,12 +1611,13 @@ router.get('/api/server-key', (req, res) => {
* GET /api/server-key/fingerprint
* Returns SHA-256 fingerprint of RS public key for out-of-band verification.
*/
router.get('/api/server-key/fingerprint', (req, res) => {
router.get('/api/server-key/fingerprint', async (req, res) => {
try {
if (!fs.existsSync(config.pubKeyPath)) {
const keyService = require('../services/keyService');
const key = await keyService.resolvePublicKey();
if (!key) {
return res.json({ fingerprint: '', algorithm: 'SHA-256' });
}
const key = fs.readFileSync(config.pubKeyPath, 'utf8').trim();
const hash = crypto.createHash('sha256').update(Buffer.from(key, 'base64')).digest('hex');
return res.json({
fingerprint: hash.match(/.{2}/g).join(':').toUpperCase(),
+127 -15
View File
@@ -1,6 +1,6 @@
/**
* BetterDesk Console - Key Service
* Reads public key and API key from filesystem
* Reads public key and API key from filesystem; resolves live Go key as fallback.
*/
const fs = require('fs');
@@ -8,21 +8,114 @@ const QRCode = require('qrcode');
const config = require('../config/config');
const conn = require('./agentBundleConnection');
const ED25519_PUBLIC_KEY_BYTES = 32;
const GO_KEY_CACHE_TTL_MS = 30_000;
/** @type {{ key: string|null, at: number }} */
let goKeyCache = { key: null, at: 0 };
/**
* Read public key from file
* True when value is a valid RustDesk server public key (base64 32 bytes).
* Rejects empty values, unresolved env tokens, and obvious placeholders.
* @param {unknown} value
* @returns {boolean}
*/
function isValidRustDeskPublicKey(value) {
if (typeof value !== 'string') return false;
const key = value.trim();
if (!key) return false;
if (/__[^_\s]+__/.test(key)) return false;
if (/placeholder/i.test(key)) return false;
if (/^YOUR[_-]?PUBLIC[_-]?KEY$/i.test(key)) return false;
if (/\s/.test(key)) return false;
try {
const decoded = Buffer.from(key, 'base64');
// Reject non-canonical base64 (padding / alphabet mismatch)
if (decoded.length !== ED25519_PUBLIC_KEY_BYTES) return false;
const reencoded = decoded.toString('base64');
// Allow missing padding on input by comparing without '='
if (reencoded.replace(/=+$/, '') !== key.replace(/=+$/, '')) return false;
return true;
} catch {
return false;
}
}
/**
* Read and validate public key from the configured pubkey file.
* Invalid / placeholder content is treated as missing (never returned to clients).
* @returns {string|null}
*/
function getPublicKey() {
try {
if (fs.existsSync(config.pubKeyPath)) {
return fs.readFileSync(config.pubKeyPath, 'utf8').trim();
if (!fs.existsSync(config.pubKeyPath)) {
return null;
}
return null;
const raw = fs.readFileSync(config.pubKeyPath, 'utf8').trim();
if (!raw) return null;
if (!isValidRustDeskPublicKey(raw)) {
console.warn(
`Public key at ${config.pubKeyPath} is not a valid Ed25519 key ` +
`(length=${raw.length}); ignoring for client deploy/config.`
);
return null;
}
return raw;
} catch (err) {
console.warn('Could not read public key:', err.message);
return null;
}
}
/**
* Fetch the live rendezvous public key from the Go server.
* @returns {Promise<string|null>}
*/
async function fetchPublicKeyFromGo() {
try {
const betterdeskApi = require('./betterdeskApi');
const resp = await betterdeskApi.apiClient.get('/server-key', { timeout: 5000 });
const key = typeof resp.data?.key === 'string' ? resp.data.key.trim() : '';
if (isValidRustDeskPublicKey(key)) {
return key;
}
return null;
} catch (err) {
console.warn('Could not fetch public key from Go /api/server-key:', err.message);
return null;
}
}
/**
* Resolve the server public key: validated file first, then live Go API (cached).
* @returns {Promise<string|null>}
*/
async function resolvePublicKey() {
// Prefer module.exports so tests can spy on getPublicKey.
const fromFile = module.exports.getPublicKey();
if (fromFile) return fromFile;
const now = Date.now();
if (goKeyCache.key && (now - goKeyCache.at) < GO_KEY_CACHE_TTL_MS) {
return goKeyCache.key;
}
const fromGo = await fetchPublicKeyFromGo();
if (fromGo) {
goKeyCache = { key: fromGo, at: now };
return fromGo;
}
goKeyCache = { key: null, at: now };
return null;
}
/** Test helper — clears Go key cache. */
function _resetGoKeyCacheForTests() {
goKeyCache = { key: null, at: 0 };
}
/**
* Get API key (masked for display)
*/
@@ -65,10 +158,12 @@ function apiUrlForHost(host, useHttps) {
/**
* RustDesk client config JSON payload: { host, relay, api, key }
* @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost
* @param {{ useHttps?: boolean }} [options]
* @param {{ useHttps?: boolean, publicKey?: string }} [options]
*/
function buildRustDeskConfigPayload(endpointsOrHost, options = {}) {
const pubKey = getPublicKey() || '';
const pubKey = options.publicKey !== undefined
? (isValidRustDeskPublicKey(options.publicKey) ? String(options.publicKey).trim() : '')
: (module.exports.getPublicKey() || '');
const useHttps = options.useHttps ?? conn.defaultUseHttps();
if (typeof endpointsOrHost === 'string') {
@@ -92,6 +187,18 @@ function buildRustDeskConfigPayload(endpointsOrHost, options = {}) {
};
}
/**
* Like buildRustDeskConfigPayload but resolves the live public key (file Go).
* @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost
* @param {{ useHttps?: boolean, publicKey?: string }} [options]
*/
async function buildRustDeskConfigPayloadAsync(endpointsOrHost, options = {}) {
const publicKey = options.publicKey !== undefined
? options.publicKey
: (await resolvePublicKey()) || '';
return buildRustDeskConfigPayload(endpointsOrHost, { ...options, publicKey });
}
/**
* QR / deep-link format: rustdesk://config/<standard-base64-json>
*/
@@ -113,17 +220,17 @@ function encodeRustDeskCliConfigString(payload) {
/**
* Generate QR code containing the RustDesk configuration URI.
* Format: rustdesk://config/<base64-encoded-json>
* Format: rustdesk://config/<base64-json>
* @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost
*/
async function getServerConfigQR(endpointsOrHost) {
const pubKey = getPublicKey();
const pubKey = await resolvePublicKey();
if (!pubKey) {
return null;
}
try {
const configPayload = buildRustDeskConfigPayload(endpointsOrHost);
const configPayload = await buildRustDeskConfigPayloadAsync(endpointsOrHost, { publicKey: pubKey });
const configUri = encodeRustDeskConfigUri(configPayload);
const qrDataUrl = await QRCode.toDataURL(configUri, {
@@ -145,10 +252,11 @@ async function getServerConfigQR(endpointsOrHost) {
/**
* Build the RustDesk client fields operators need to enter manually.
* Uses validated file key, then live Go /api/server-key as fallback (#340).
* @param {{ host: string, relay?: string, api?: string } | string} endpointsOrHost
*/
function getClientConfig(endpointsOrHost) {
const payload = buildRustDeskConfigPayload(endpointsOrHost);
async function getClientConfig(endpointsOrHost) {
const payload = await buildRustDeskConfigPayloadAsync(endpointsOrHost);
const publicKey = payload.key;
return {
@@ -166,7 +274,7 @@ function getClientConfig(endpointsOrHost) {
* Generate QR code for public key (legacy raw key text)
*/
async function getPublicKeyQR() {
const pubKey = getPublicKey();
const pubKey = await resolvePublicKey();
if (!pubKey) {
return null;
}
@@ -192,9 +300,9 @@ async function getPublicKeyQR() {
/**
* Get server configuration info
*/
function getServerConfig() {
async function getServerConfig() {
return {
publicKey: getPublicKey(),
publicKey: await resolvePublicKey(),
apiKeyMasked: getApiKey(true),
hbbsApiUrl: config.hbbsApiUrl,
dbPath: config.dbPath,
@@ -204,15 +312,19 @@ function getServerConfig() {
}
module.exports = {
isValidRustDeskPublicKey,
getPublicKey,
resolvePublicKey,
getApiKey,
getPublicKeyQR,
getServerConfigQR,
getClientConfig,
getServerConfig,
buildRustDeskConfigPayload,
buildRustDeskConfigPayloadAsync,
encodeRustDeskConfigUri,
encodeRustDeskCliConfigString,
normalizeHostInput,
apiUrlForHost,
_resetGoKeyCacheForTests,
};
+79 -2
View File
@@ -27,7 +27,9 @@ describe('keyService RustDesk config encoding', () => {
});
it('buildRustDeskConfigPayload normalizes host input', () => {
const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path');
const payload = keyService.buildRustDeskConfigPayload('https://desk.example.com:8443/path', {
publicKey: '',
});
expect(payload.host).toBe('desk.example.com');
expect(payload.relay).toBe('desk.example.com');
});
@@ -37,9 +39,84 @@ describe('keyService RustDesk config encoding', () => {
host: 'remote.example.com',
relay: 'relay.example.com',
api: 'https://api.example.com',
});
}, { publicKey: '' });
expect(payload.host).toBe('remote.example.com');
expect(payload.relay).toBe('relay.example.com');
expect(payload.api).toBe('https://api.example.com');
});
});
describe('keyService public key validation (#340)', () => {
// 32 zero bytes → canonical base64 (44 chars with padding)
const validKey = Buffer.alloc(32, 0).toString('base64');
afterEach(() => {
keyService._resetGoKeyCacheForTests();
jest.restoreAllMocks();
});
it('isValidRustDeskPublicKey accepts canonical 32-byte base64', () => {
expect(keyService.isValidRustDeskPublicKey(validKey)).toBe(true);
});
it('isValidRustDeskPublicKey rejects placeholders and junk', () => {
expect(keyService.isValidRustDeskPublicKey('')).toBe(false);
expect(keyService.isValidRustDeskPublicKey('v1.4.9_public_key_placeholder...')).toBe(false);
expect(keyService.isValidRustDeskPublicKey('YOUR_PUBLIC_KEY')).toBe(false);
expect(keyService.isValidRustDeskPublicKey('__PUB_KEY_PATH__')).toBe(false);
expect(keyService.isValidRustDeskPublicKey('not-base64!!!')).toBe(false);
expect(keyService.isValidRustDeskPublicKey(Buffer.alloc(16).toString('base64'))).toBe(false);
});
it('getClientConfig embeds valid public key in deploy string', async () => {
const payload = keyService.buildRustDeskConfigPayload('desk.example.com', { publicKey: validKey });
const deploy = keyService.encodeRustDeskCliConfigString(payload);
const json = Buffer.from(deploy.split('').reverse().join(''), 'base64').toString('utf8');
const decoded = JSON.parse(json);
expect(decoded.key).toBe(validKey);
expect(decoded.host).toBe('desk.example.com');
expect(payload.key).toBe(validKey);
});
it('buildRustDeskConfigPayload drops invalid publicKey override', () => {
const payload = keyService.buildRustDeskConfigPayload('desk.example.com', {
publicKey: 'v1.4.9_public_key_placeholder...',
});
expect(payload.key).toBe('');
});
it('getClientConfig with invalid file key and Go fallback fills deploy string', async () => {
jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null);
const betterdeskApi = require('../services/betterdeskApi');
jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({ data: { key: validKey } });
const config = await keyService.getClientConfig({
host: '203.0.113.10',
relay: '203.0.113.10',
api: 'http://203.0.113.10:21114',
});
expect(config.has_public_key).toBe(true);
expect(config.public_key).toBe(validKey);
expect(config.deploy_config_string).toBeTruthy();
const json = Buffer.from(
config.deploy_config_string.split('').reverse().join(''),
'base64'
).toString('utf8');
expect(JSON.parse(json).key).toBe(validKey);
});
it('getClientConfig leaves deploy string empty when no valid key exists', async () => {
jest.spyOn(keyService, 'getPublicKey').mockReturnValue(null);
const betterdeskApi = require('../services/betterdeskApi');
jest.spyOn(betterdeskApi.apiClient, 'get').mockResolvedValue({
data: { key: 'v1.4.9_public_key_placeholder...' },
});
const config = await keyService.getClientConfig('desk.example.com');
expect(config.has_public_key).toBe(false);
expect(config.public_key).toBe('');
expect(config.deploy_config_string).toBe('');
});
});