mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
2e01955f6c
- Introduced server URL validation via `GET /api/bd/server-info` and `probe_server_url`. - Added LAN discovery capabilities using UDP and optional mDNS for local network panel detection. - Implemented a local settings window for managing URL, TLS settings, and user preferences. - Enhanced dashboard with a unified sidebar and improved scrolling behavior. - Updated documentation and pre-release checklist to reflect new features and requirements.
285 lines
9.3 KiB
JavaScript
285 lines
9.3 KiB
JavaScript
/**
|
|
* BetterDesk RdClient — encrypted credential storage (browser).
|
|
* Passwords are stored in IndexedDB with AES-GCM (Web Crypto); never plaintext in localStorage.
|
|
*/
|
|
var RdClientSecureStore = (function () {
|
|
'use strict';
|
|
|
|
var DB_NAME = 'betterdesk-rdclient';
|
|
var DB_VERSION = 2;
|
|
var STORE = 'vault';
|
|
var KEY_ID = 'device-key';
|
|
var CRED_ID = 'credentials';
|
|
var USERNAME_KEY = 'bd_rdclient_last_user';
|
|
|
|
function openDb() {
|
|
return new Promise(function (resolve, reject) {
|
|
if (!window.indexedDB) {
|
|
reject(new Error('IndexedDB unavailable'));
|
|
return;
|
|
}
|
|
var req = indexedDB.open(DB_NAME, DB_VERSION);
|
|
req.onerror = function () { reject(req.error || new Error('IndexedDB open failed')); };
|
|
req.onupgradeneeded = function (ev) {
|
|
var db = ev.target.result;
|
|
if (!db.objectStoreNames.contains(STORE)) {
|
|
db.createObjectStore(STORE);
|
|
}
|
|
};
|
|
req.onsuccess = function () { resolve(req.result); };
|
|
});
|
|
}
|
|
|
|
function idbGet(db, key) {
|
|
return new Promise(function (resolve, reject) {
|
|
var tx = db.transaction(STORE, 'readonly');
|
|
var req = tx.objectStore(STORE).get(key);
|
|
req.onsuccess = function () { resolve(req.result); };
|
|
req.onerror = function () { reject(req.error); };
|
|
});
|
|
}
|
|
|
|
function idbPut(db, key, value) {
|
|
return new Promise(function (resolve, reject) {
|
|
var tx = db.transaction(STORE, 'readwrite');
|
|
var req = tx.objectStore(STORE).put(value, key);
|
|
req.onsuccess = function () { resolve(); };
|
|
req.onerror = function () { reject(req.error); };
|
|
});
|
|
}
|
|
|
|
function idbDelete(db, key) {
|
|
return new Promise(function (resolve, reject) {
|
|
var tx = db.transaction(STORE, 'readwrite');
|
|
var req = tx.objectStore(STORE).delete(key);
|
|
req.onsuccess = function () { resolve(); };
|
|
req.onerror = function () { reject(req.error); };
|
|
});
|
|
}
|
|
|
|
function bufToB64(buf) {
|
|
var bytes = new Uint8Array(buf);
|
|
var bin = '';
|
|
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
return btoa(bin);
|
|
}
|
|
|
|
function b64ToBuf(b64) {
|
|
var bin = atob(b64);
|
|
var bytes = new Uint8Array(bin.length);
|
|
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
return bytes.buffer;
|
|
}
|
|
|
|
async function getOrCreateDeviceKey(db) {
|
|
var existing = await idbGet(db, KEY_ID);
|
|
if (existing && existing.key) {
|
|
return crypto.subtle.importKey(
|
|
'raw',
|
|
b64ToBuf(existing.key),
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
var raw = crypto.getRandomValues(new Uint8Array(32));
|
|
await idbPut(db, KEY_ID, { key: bufToB64(raw.buffer) });
|
|
return crypto.subtle.importKey(
|
|
'raw',
|
|
raw,
|
|
{ name: 'AES-GCM', length: 256 },
|
|
false,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
}
|
|
|
|
async function encryptPassword(key, password) {
|
|
var iv = crypto.getRandomValues(new Uint8Array(12));
|
|
var enc = new TextEncoder().encode(password);
|
|
var cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, enc);
|
|
return { iv: bufToB64(iv.buffer), data: bufToB64(cipher) };
|
|
}
|
|
|
|
async function decryptPassword(key, payload) {
|
|
if (!payload || !payload.iv || !payload.data) return '';
|
|
var iv = new Uint8Array(b64ToBuf(payload.iv));
|
|
var data = b64ToBuf(payload.data);
|
|
var plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv }, key, data);
|
|
return new TextDecoder().decode(plain);
|
|
}
|
|
|
|
function saveLastUsername(username) {
|
|
try {
|
|
if (username) localStorage.setItem(USERNAME_KEY, username);
|
|
else localStorage.removeItem(USERNAME_KEY);
|
|
} catch (_) { /* quota / private mode */ }
|
|
}
|
|
|
|
function loadLastUsername() {
|
|
try {
|
|
return localStorage.getItem(USERNAME_KEY) || '';
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async function saveCredentials(username, password, rememberPassword) {
|
|
saveLastUsername(username);
|
|
if (!rememberPassword || !password) {
|
|
await clearStoredPassword();
|
|
return;
|
|
}
|
|
if (!window.crypto || !window.crypto.subtle) return;
|
|
var db = await openDb();
|
|
try {
|
|
var key = await getOrCreateDeviceKey(db);
|
|
var encrypted = await encryptPassword(key, password);
|
|
await idbPut(db, CRED_ID, {
|
|
username: username,
|
|
password: encrypted,
|
|
rememberPassword: true,
|
|
updatedAt: Date.now()
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function clearStoredPassword() {
|
|
if (!window.indexedDB) return;
|
|
var db = await openDb();
|
|
try {
|
|
await idbDelete(db, CRED_ID);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function clearCredentials() {
|
|
saveLastUsername('');
|
|
await clearStoredPassword();
|
|
}
|
|
|
|
async function loadCredentials() {
|
|
var username = loadLastUsername();
|
|
var result = { username: username, password: '', rememberPassword: false };
|
|
if (!window.crypto || !window.crypto.subtle || !window.indexedDB) {
|
|
return result;
|
|
}
|
|
var db = await openDb();
|
|
try {
|
|
var record = await idbGet(db, CRED_ID);
|
|
if (!record || !record.rememberPassword || !record.password) {
|
|
return result;
|
|
}
|
|
var key = await getOrCreateDeviceKey(db);
|
|
var password = await decryptPassword(key, record.password);
|
|
result.username = record.username || username;
|
|
result.password = password;
|
|
result.rememberPassword = true;
|
|
return result;
|
|
} catch (_) {
|
|
return result;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function hasStoredPassword() {
|
|
if (!window.indexedDB) return false;
|
|
var db = await openDb();
|
|
try {
|
|
var record = await idbGet(db, CRED_ID);
|
|
return !!(record && record.rememberPassword && record.password);
|
|
} catch (_) {
|
|
return false;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function peerKey(deviceId) {
|
|
return 'peer:' + String(deviceId || '').trim();
|
|
}
|
|
|
|
async function savePeerPassword(deviceId, password) {
|
|
if (!deviceId || !password || !window.crypto || !window.crypto.subtle) return;
|
|
var db = await openDb();
|
|
try {
|
|
var key = await getOrCreateDeviceKey(db);
|
|
var encrypted = await encryptPassword(key, password);
|
|
await idbPut(db, peerKey(deviceId), {
|
|
encrypted: encrypted,
|
|
updatedAt: Date.now()
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function loadPeerPassword(deviceId) {
|
|
if (!deviceId || !window.crypto || !window.crypto.subtle || !window.indexedDB) return '';
|
|
var db = await openDb();
|
|
try {
|
|
var record = await idbGet(db, peerKey(deviceId));
|
|
if (!record || !record.encrypted) return '';
|
|
var key = await getOrCreateDeviceKey(db);
|
|
return await decryptPassword(key, record.encrypted);
|
|
} catch (_) {
|
|
return '';
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function clearPeerPassword(deviceId) {
|
|
if (!deviceId || !window.indexedDB) return;
|
|
var db = await openDb();
|
|
try {
|
|
await idbDelete(db, peerKey(deviceId));
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function clearAllPeerPasswords() {
|
|
if (!window.indexedDB) return;
|
|
var db = await openDb();
|
|
try {
|
|
var tx = db.transaction(STORE, 'readwrite');
|
|
var store = tx.objectStore(STORE);
|
|
var req = store.openCursor();
|
|
await new Promise(function (resolve, reject) {
|
|
req.onsuccess = function () {
|
|
var cursor = req.result;
|
|
if (!cursor) { resolve(); return; }
|
|
if (String(cursor.key).indexOf('peer:') === 0) {
|
|
cursor.delete();
|
|
}
|
|
cursor.continue();
|
|
};
|
|
req.onerror = function () { reject(req.error); };
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
return {
|
|
saveCredentials: saveCredentials,
|
|
loadCredentials: loadCredentials,
|
|
clearCredentials: clearCredentials,
|
|
clearStoredPassword: clearStoredPassword,
|
|
hasStoredPassword: hasStoredPassword,
|
|
loadLastUsername: loadLastUsername,
|
|
savePeerPassword: savePeerPassword,
|
|
loadPeerPassword: loadPeerPassword,
|
|
clearPeerPassword: clearPeerPassword,
|
|
clearAllPeerPasswords: clearAllPeerPasswords
|
|
};
|
|
})();
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.RdClientSecureStore = RdClientSecureStore;
|
|
}
|