@livedesk/client 0.1.222 → 0.1.224

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,15 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
 
3
3
  import net from 'net';
4
4
  import os from 'os';
5
5
  import path from 'path';
6
6
  import crypto from 'crypto';
7
- import { existsSync, promises as fs, statfsSync } from 'fs';
7
+ import { constants as fsConstants, existsSync, promises as fs, statfsSync } from 'fs';
8
8
  import { spawn } from 'child_process';
9
- import { createRequire } from 'node:module';
10
- import { fileURLToPath } from 'node:url';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { createClientDeviceCredentialStore } from '../src/security/device-credential-store.js';
12
+ import { connectSecureDirect } from '../src/security/secure-direct-client.js';
11
13
 
12
14
  const require = createRequire(import.meta.url);
13
15
  const CLIENT_UPDATE_BOOTSTRAP_PATH = fileURLToPath(
@@ -657,7 +659,7 @@ function sanitizePathSegment(value, fallback = 'file') {
657
659
  return normalized.slice(0, 160);
658
660
  }
659
661
 
660
- function sanitizeRelativeFilePath(value, fallbackName = 'file') {
662
+ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
661
663
  const parts = String(value || '')
662
664
  .replace(/\0/g, '')
663
665
  .split(/[\\/]+/)
@@ -668,7 +670,52 @@ function sanitizeRelativeFilePath(value, fallbackName = 'file') {
668
670
  return sanitizePathSegment(fallbackName, 'file');
669
671
  }
670
672
  return path.join(...parts.slice(-8));
671
- }
673
+ }
674
+
675
+ function isPathInsideRoot(root, candidate) {
676
+ const relative = path.relative(root, candidate);
677
+ return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
678
+ }
679
+
680
+ async function resolveReceiveDirectory(options, remoteDirectory) {
681
+ const configuredRoot = path.resolve(options.filesDir || getDefaultFilesDir());
682
+ await fs.mkdir(configuredRoot, { recursive: true });
683
+ const rootStat = await fs.lstat(configuredRoot);
684
+ if (rootStat.isSymbolicLink()) throw new Error('The LiveDesk receive root cannot be a symbolic link or junction.');
685
+ const canonicalRoot = await fs.realpath(configuredRoot);
686
+ const relativeDirectory = String(remoteDirectory || '').replace(/\0/g, '').trim();
687
+ if (relativeDirectory.length > 600 || path.isAbsolute(relativeDirectory)) {
688
+ throw new Error('The remote receive directory must be relative to the LiveDesk files root.');
689
+ }
690
+ const candidate = path.resolve(canonicalRoot, relativeDirectory || '.');
691
+ if (!isPathInsideRoot(canonicalRoot, candidate)) throw new Error('The remote receive directory escapes the LiveDesk files root.');
692
+ await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
693
+ await fs.mkdir(candidate, { recursive: true });
694
+ await assertAgentPathDoesNotTraverseLink(canonicalRoot, candidate);
695
+ const canonicalCandidate = await fs.realpath(candidate);
696
+ if (!isPathInsideRoot(canonicalRoot, canonicalCandidate)) throw new Error('The remote receive directory resolves outside the LiveDesk files root.');
697
+ return { root: canonicalRoot, directory: canonicalCandidate };
698
+ }
699
+
700
+ function requireFileSha256(value) {
701
+ const hash = String(value || '').trim().toLowerCase();
702
+ if (!/^[a-f0-9]{64}$/.test(hash)) throw new Error('A valid SHA-256 file hash is required.');
703
+ return hash;
704
+ }
705
+
706
+ function hashBuffer(buffer) {
707
+ return crypto.createHash('sha256').update(buffer).digest('hex');
708
+ }
709
+
710
+ async function commitNewFile(tempPath, targetPath) {
711
+ try {
712
+ await fs.link(tempPath, targetPath);
713
+ } catch (error) {
714
+ if (error?.code === 'EEXIST') throw new Error(`Refusing to overwrite an existing file: ${path.basename(targetPath)}`);
715
+ throw error;
716
+ }
717
+ await fs.rm(tempPath, { force: true });
718
+ }
672
719
 
673
720
  async function handleFileTransferCommand(options, payload = {}) {
674
721
  const files = Array.isArray(payload.files) ? payload.files.slice(0, MAX_FILE_TRANSFER_FILES) : [];
@@ -676,32 +723,46 @@ async function handleFileTransferCommand(options, payload = {}) {
676
723
  throw new Error('No files were included in the transfer.');
677
724
  }
678
725
 
679
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
680
- await fs.mkdir(baseDir, { recursive: true });
726
+ const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
681
727
 
682
728
  let totalBytes = 0;
683
729
  const saved = [];
684
730
  for (const file of files) {
685
731
  const name = sanitizePathSegment(file?.name, 'file');
686
732
  const relativePath = sanitizeRelativeFilePath(file?.relativePath || name, name);
687
- const targetPath = path.resolve(baseDir, relativePath);
688
- const relativeFromBase = path.relative(baseDir, targetPath);
689
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
690
- throw new Error(`Unsafe file path: ${relativePath}`);
691
- }
733
+ const targetPath = path.resolve(baseDir, relativePath);
734
+ if (!isPathInsideRoot(baseDir, targetPath)) {
735
+ throw new Error(`Unsafe file path: ${relativePath}`);
736
+ }
692
737
 
693
738
  const dataBase64 = String(file?.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
694
739
  if (!dataBase64) {
695
740
  throw new Error(`Missing file data: ${name}`);
696
741
  }
697
- const buffer = Buffer.from(dataBase64, 'base64');
742
+ const buffer = Buffer.from(dataBase64, 'base64');
743
+ const expectedSha256 = requireFileSha256(file?.sha256);
744
+ if (hashBuffer(buffer) !== expectedSha256) throw new Error(`File SHA-256 mismatch: ${name}`);
698
745
  totalBytes += buffer.length;
699
746
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
700
747
  throw new Error('File transfer exceeded the local size limit.');
701
748
  }
702
749
 
703
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
704
- await fs.writeFile(targetPath, buffer);
750
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
751
+ await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
752
+ await assertAgentPathDoesNotTraverseLink(root, targetPath);
753
+ const tempPath = `${targetPath}.livedesk-${crypto.randomBytes(12).toString('hex')}.part`;
754
+ try {
755
+ const handle = await fs.open(tempPath, 'wx', 0o600);
756
+ try {
757
+ await handle.writeFile(buffer);
758
+ await handle.sync();
759
+ } finally {
760
+ await handle.close();
761
+ }
762
+ await commitNewFile(tempPath, targetPath);
763
+ } finally {
764
+ await fs.rm(tempPath, { force: true }).catch(() => undefined);
765
+ }
705
766
  saved.push({
706
767
  name,
707
768
  relativePath,
@@ -722,12 +783,11 @@ async function handleFileTransferCommand(options, payload = {}) {
722
783
  }
723
784
 
724
785
  async function handleFileTransferChunkCommand(options, payload = {}) {
725
- const baseDir = normalizeDirectoryPath(payload.remoteDirectory || options.filesDir);
786
+ const { root, directory: baseDir } = await resolveReceiveDirectory(options, payload.remoteDirectory);
726
787
  const name = sanitizePathSegment(payload.name, 'file');
727
788
  const relativePath = sanitizeRelativeFilePath(payload.relativePath || name, name);
728
789
  const targetPath = path.resolve(baseDir, relativePath);
729
- const relativeFromBase = path.relative(baseDir, targetPath);
730
- if (relativeFromBase.startsWith('..') || path.isAbsolute(relativeFromBase)) {
790
+ if (!isPathInsideRoot(baseDir, targetPath)) {
731
791
  throw new Error(`Unsafe file path: ${relativePath}`);
732
792
  }
733
793
 
@@ -736,19 +796,31 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
736
796
  const totalBytes = Math.max(0, Math.floor(Number(payload.totalBytes) || 0));
737
797
  const final = payload.final === true;
738
798
  const buffer = payload.dataBase64 ? Buffer.from(String(payload.dataBase64), 'base64') : Buffer.alloc(0);
739
- if (!transferId || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
799
+ if (!transferId || totalBytes > MAX_FILE_TRANSFER_BYTES || offset > totalBytes || offset + buffer.length > totalBytes || buffer.length > 512 * 1024) {
740
800
  throw new Error('Invalid file transfer chunk.');
741
801
  }
742
802
  if (buffer.length === 0 && !(final && totalBytes === 0 && offset === 0)) {
743
803
  throw new Error('Empty file chunks are not allowed.');
744
804
  }
745
805
 
746
- await fs.mkdir(path.dirname(targetPath), { recursive: true });
747
- const tempPath = `${targetPath}.livedesk-${transferId}.part`;
748
- const handle = await fs.open(tempPath, offset === 0 ? 'w+' : 'r+');
806
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
807
+ await assertAgentPathDoesNotTraverseLink(root, path.dirname(targetPath));
808
+ await assertAgentPathDoesNotTraverseLink(root, targetPath);
809
+ const tempPath = `${targetPath}.livedesk-${transferId}.part`;
810
+ if (offset > 0) {
811
+ const staging = await fs.lstat(tempPath);
812
+ if (!staging.isFile() || staging.isSymbolicLink() || staging.nlink !== 1) {
813
+ throw new Error('Unsafe file transfer staging path.');
814
+ }
815
+ }
816
+ const openFlags = offset === 0
817
+ ? fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR
818
+ : fsConstants.O_RDWR | (fsConstants.O_NOFOLLOW || 0);
819
+ const handle = await fs.open(tempPath, openFlags, 0o600);
749
820
  let completedSize = 0;
750
821
  try {
751
- const before = await handle.stat();
822
+ const before = await handle.stat();
823
+ if (!before.isFile() || before.nlink !== 1) throw new Error('Unsafe file transfer staging path.');
752
824
  if (offset > before.size) {
753
825
  throw new Error(`File chunk gap detected at ${offset}; current length is ${before.size}.`);
754
826
  }
@@ -767,9 +839,19 @@ async function handleFileTransferChunkCommand(options, payload = {}) {
767
839
  await handle.close();
768
840
  }
769
841
 
770
- if (final) {
771
- await fs.rm(targetPath, { force: true });
772
- await fs.rename(tempPath, targetPath);
842
+ if (final) {
843
+ const expectedSha256 = requireFileSha256(payload.sha256);
844
+ const received = await fs.readFile(tempPath);
845
+ if (hashBuffer(received) !== expectedSha256) {
846
+ await fs.rm(tempPath, { force: true });
847
+ throw new Error('File SHA-256 mismatch.');
848
+ }
849
+ try {
850
+ await commitNewFile(tempPath, targetPath);
851
+ } catch (error) {
852
+ await fs.rm(tempPath, { force: true });
853
+ throw error;
854
+ }
773
855
  const lastModified = Number(payload.lastModified || 0);
774
856
  if (lastModified > 0) {
775
857
  const modifiedAt = new Date(lastModified);
@@ -1430,7 +1512,20 @@ function normalizeNodeAgentTaskResult(result) {
1430
1512
  return { ...result, ok, status: ok ? 'completed' : 'failed', error };
1431
1513
  }
1432
1514
 
1433
- const NODE_AGENT_OPERATIONS = new Set(['process.control', 'service.control', 'application.launch', 'application.close', 'file.read', 'file.write', 'file.delete', 'file.list', 'command.run', 'script.run', 'software.install', 'network.status', 'system.power', 'system.configure', 'logs.collect']);
1515
+ const NODE_AGENT_OPERATIONS = new Set(['file.read', 'file.list', 'network.status', 'logs.collect']);
1516
+ const RETIRED_NODE_AGENT_MUTATING_OPERATIONS = new Set([
1517
+ 'process.control',
1518
+ 'service.control',
1519
+ 'application.launch',
1520
+ 'application.close',
1521
+ 'file.write',
1522
+ 'file.delete',
1523
+ 'command.run',
1524
+ 'script.run',
1525
+ 'software.install',
1526
+ 'system.power',
1527
+ 'system.configure'
1528
+ ]);
1434
1529
 
1435
1530
  function remotePolicyAllows(options, command) {
1436
1531
  const policy = options.effectivePolicy;
@@ -1587,8 +1682,8 @@ async function scheduleClientUpdate(options, payload = {}) {
1587
1682
  }
1588
1683
  }
1589
1684
 
1590
- async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1591
- const command = String(message.command || '');
1685
+ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activeStreams) {
1686
+ const command = String(message.command || '');
1592
1687
  if (command === 'ping') {
1593
1688
  writeJsonLine(socket, {
1594
1689
  type: 'command.result',
@@ -1598,8 +1693,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1598
1693
  at: new Date().toISOString()
1599
1694
  }
1600
1695
  });
1601
- return;
1602
- }
1696
+ return;
1697
+ }
1698
+
1699
+ // LD-SEC-AI-001 is enforced before settings or permission modes. A stale
1700
+ // Hub can therefore never revive a retired mutation with full-access.
1701
+ if (RETIRED_NODE_AGENT_MUTATING_OPERATIONS.has(command)) {
1702
+ writeJsonLine(socket, {
1703
+ type: 'command.result',
1704
+ commandId: message.commandId,
1705
+ error: 'agent-mutating-tool-disabled',
1706
+ result: { ok: false, status: 'rejected', error: 'agent-mutating-tool-disabled', sideEffects: 'none' }
1707
+ });
1708
+ return;
1709
+ }
1603
1710
 
1604
1711
  const policyDecision = remotePolicyAllows(options, command);
1605
1712
  if (!policyDecision.ok) {
@@ -1900,18 +2007,20 @@ async function handleRemoteCommand(socket, options, message, nextFrameSeq, activ
1900
2007
  });
1901
2008
  }
1902
2009
 
1903
- function connectOnce(options, deviceId) {
1904
- const manager = parseManagerAddress(options.manager);
1905
- if (!isPrivateLanHost(manager.host) && !isTruthy(process.env.LIVEDESK_ALLOW_UNENCRYPTED_LAN)) {
1906
- return Promise.reject(new Error('Plain TCP LiveDesk connections are limited to loopback/private LAN. Use an encrypted endpoint for external connections.'));
1907
- }
1908
- return new Promise((resolve, reject) => {
1909
- const socket = net.createConnection({
1910
- host: manager.host,
1911
- port: manager.port
1912
- });
1913
-
1914
- let buffer = '';
2010
+ async function connectOnce(options, deviceId) {
2011
+ const manager = parseManagerAddress(options.manager);
2012
+ const credentialStore = createClientDeviceCredentialStore({ deviceId });
2013
+ const socket = await connectSecureDirect({
2014
+ host: manager.host,
2015
+ port: manager.port,
2016
+ channel: 'control',
2017
+ deviceId,
2018
+ enrollmentToken: options.pair,
2019
+ credentialStore,
2020
+ timeoutMs: 5000
2021
+ });
2022
+ return new Promise((resolve, reject) => {
2023
+ let buffer = '';
1915
2024
  let heartbeatTimer = null;
1916
2025
  let resolved = false;
1917
2026
  let frameSeq = 0;
@@ -1945,44 +2054,43 @@ function connectOnce(options, deviceId) {
1945
2054
  socket.setNoDelay(true);
1946
2055
  socket.setKeepAlive(true, options.heartbeatMs);
1947
2056
 
1948
- socket.once('connect', () => {
1949
- writeJsonLine(socket, {
1950
- type: 'hello',
1951
- pairToken: options.pair,
1952
- deviceId,
1953
- deviceName: options.name,
1954
- slotNumber: options.slotNumber || undefined,
1955
- hostname: os.hostname(),
1956
- platform: os.platform(),
1957
- arch: os.arch(),
1958
- pid: process.pid,
1959
- agentVersion: AGENT_VERSION,
1960
- productVersion: PRODUCT_VERSION,
1961
- capabilities: {
1962
- status: true,
1963
- thumbnail: options.thumbnailEnabled,
1964
- liveStream: options.liveEnabled,
1965
- monitorSelection: true,
1966
- screenCount: 1,
1967
- monitorCount: 1,
1968
- control: false,
1969
- audio: false,
1970
- remoteAudio: false,
1971
- fileTransfer: true,
1972
- remoteFiles: true,
1973
- fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
1974
- computerAgent: options.taskEnabled,
1975
- taskDispatch: options.taskEnabled,
1976
- clientUpdate: true,
1977
- productVersion: PRODUCT_VERSION,
1978
- agentApproval: options.taskEnabled,
1979
- agentAudit: options.taskEnabled,
1980
- agentTools: [...NODE_AGENT_OPERATIONS],
1981
- elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
1982
- externalEffects: options.taskEnabled
1983
- }
1984
- });
1985
- });
2057
+ writeJsonLine(socket, {
2058
+ type: 'hello',
2059
+ deviceId,
2060
+ deviceName: options.name,
2061
+ slotNumber: options.slotNumber || undefined,
2062
+ hostname: os.hostname(),
2063
+ platform: os.platform(),
2064
+ arch: os.arch(),
2065
+ pid: process.pid,
2066
+ agentVersion: AGENT_VERSION,
2067
+ productVersion: PRODUCT_VERSION,
2068
+ capabilities: {
2069
+ status: true,
2070
+ thumbnail: options.thumbnailEnabled,
2071
+ liveStream: options.liveEnabled,
2072
+ monitorSelection: true,
2073
+ screenCount: 1,
2074
+ monitorCount: 1,
2075
+ control: false,
2076
+ audio: false,
2077
+ remoteAudio: false,
2078
+ fileTransfer: true,
2079
+ remoteFiles: true,
2080
+ fileTransferMaxBytes: MAX_FILE_TRANSFER_BYTES,
2081
+ computerAgent: options.taskEnabled,
2082
+ taskDispatch: options.taskEnabled,
2083
+ clientUpdate: true,
2084
+ productVersion: PRODUCT_VERSION,
2085
+ agentApproval: options.taskEnabled,
2086
+ agentAudit: options.taskEnabled,
2087
+ agentTools: [...NODE_AGENT_OPERATIONS],
2088
+ elevation: typeof process.getuid === 'function' ? process.getuid() === 0 : false,
2089
+ externalEffects: false,
2090
+ authenticatedDeviceCredential: true,
2091
+ encryptedDirect: true
2092
+ }
2093
+ });
1986
2094
 
1987
2095
  socket.on('data', chunk => {
1988
2096
  buffer += chunk;
@@ -20,11 +20,14 @@ import {
20
20
  } from '../src/runtime/agent-process-lifecycle.js';
21
21
  import { writeWindowsOwnedProcessManifest } from '../src/runtime/windows-owned-process-manifest.js';
22
22
  import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
23
+ import { createClientDeviceCredentialStore } from '../src/security/device-credential-store.js';
24
+ import { connectSecureDirect } from '../src/security/secure-direct-client.js';
23
25
  import {
24
26
  inspectLinuxVideoAcceleration,
25
27
  installLinuxVideoAcceleration
26
28
  } from '../src/runtime/linux-video-acceleration.js';
27
29
  import { normalizeRuntimeAuthSession } from '../../runtime-core/src/auth-session.js';
30
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
28
31
  import { startRoleTransitionSupervisor } from '../../runtime-core/src/role-transition-supervisor.js';
29
32
 
30
33
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -66,6 +69,11 @@ const UNIFIED_CLIENT_AUTH_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'auth.json');
66
69
  const UNIFIED_CLIENT_PIN_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'pin.json');
67
70
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
68
71
  const CLIENT_HUB_TARGET_CACHE_STORAGE_KEY = 'livedesk.client.last-hub-target';
72
+ const CLIENT_REFRESH_SECRET_STORE = createOsSecretStore({
73
+ service: 'LiveDesk',
74
+ account: 'client-refresh-token',
75
+ dataDir: UNIFIED_CLIENT_STATE_DIR
76
+ });
69
77
  const DEVICE_ROLE_CACHE_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'device-role.json');
70
78
  const FAST_PREFLIGHT_CACHE_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'fast-preflight.json');
71
79
  const CLIENT_SLOT_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'device-slot.json');
@@ -1051,10 +1059,23 @@ function readSavedSessionFromFile() {
1051
1059
  for (const path of [preferredClientAuthPath(), CLIENT_AUTH_PATH, UNIFIED_CLIENT_AUTH_PATH]) {
1052
1060
  try {
1053
1061
  const state = JSON.parse(readFileSync(path, 'utf8'));
1054
- const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
1055
- if (typeof raw !== 'string' || !raw.trim()) continue;
1062
+ const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
1063
+ if (typeof raw !== 'string' || !raw.trim()) continue;
1056
1064
  const session = JSON.parse(raw);
1057
- const normalized = normalizeRuntimeAuthSession(session, { requireRefreshToken: true });
1065
+ const plaintextRefreshToken = String(session?.refresh_token || '').trim();
1066
+ const refreshToken = plaintextRefreshToken || (session?.refresh_token_ref === OS_SECRET_REFERENCE
1067
+ ? CLIENT_REFRESH_SECRET_STORE.read()
1068
+ : '');
1069
+ if (plaintextRefreshToken) {
1070
+ if (!CLIENT_REFRESH_SECRET_STORE.write(plaintextRefreshToken)) {
1071
+ createFileStorage(path).removeItem(CLIENT_AUTH_STORAGE_KEY);
1072
+ continue;
1073
+ }
1074
+ const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
1075
+ delete migrated.refresh_token;
1076
+ createFileStorage(path).setItem(CLIENT_AUTH_STORAGE_KEY, JSON.stringify(migrated));
1077
+ }
1078
+ const normalized = normalizeRuntimeAuthSession({ ...session, refresh_token: refreshToken }, { requireRefreshToken: true });
1058
1079
  if (normalized.ok) return { ...session, ...normalized.session };
1059
1080
  } catch {
1060
1081
  // Try the next compatible state location.
@@ -1068,12 +1089,16 @@ function writeSavedSessionToFile(session) {
1068
1089
  if (!normalized.ok) {
1069
1090
  return false;
1070
1091
  }
1092
+ if (!CLIENT_REFRESH_SECRET_STORE.write(normalized.session.refresh_token)) return false;
1071
1093
  const storage = createFileStorage(preferredClientAuthPath());
1072
- storage.setItem(CLIENT_AUTH_STORAGE_KEY, JSON.stringify({ ...session, ...normalized.session }));
1094
+ const persisted = { ...session, ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
1095
+ delete persisted.refresh_token;
1096
+ storage.setItem(CLIENT_AUTH_STORAGE_KEY, JSON.stringify(persisted));
1073
1097
  return true;
1074
1098
  }
1075
-
1076
- function clearSavedSession() {
1099
+
1100
+ function clearSavedSession() {
1101
+ CLIENT_REFRESH_SECRET_STORE.clear();
1077
1102
  rmSync(CLIENT_AUTH_PATH, { force: true });
1078
1103
  rmSync(UNIFIED_CLIENT_AUTH_PATH, { force: true });
1079
1104
  }
@@ -3119,7 +3144,7 @@ async function startConnectionChoiceServer(supabase, options = {}) {
3119
3144
 
3120
3145
  if (requestUrl.pathname === '/logout') {
3121
3146
  try {
3122
- await supabase.auth.signOut();
3147
+ await supabase.auth.signOut({ scope: 'local' });
3123
3148
  } catch {
3124
3149
  }
3125
3150
  clearSavedSession();
@@ -3570,45 +3595,57 @@ export function shouldSkipAutomaticDirectProbe(endpoint, options = {}) {
3570
3595
  && !isEndpointOnLocalNetwork(endpoint, options.networkInterfaces);
3571
3596
  }
3572
3597
 
3573
- function requestHubSlotAssignment({ manager, pairToken, deviceId, slotNumber, timeoutMs = 5000 }) {
3598
+ export async function requestHubSlotAssignment({ manager, pairToken, deviceId, slotNumber, timeoutMs = 5000 }) {
3574
3599
  const endpoint = parseManagerEndpoint(manager);
3575
3600
  const normalizedPairToken = String(pairToken || '').trim();
3576
3601
  const normalizedDeviceId = String(deviceId || '').trim();
3577
3602
  const normalizedSlot = normalizeSlotNumber(slotNumber);
3578
3603
  if (!endpoint) {
3579
- return Promise.resolve({ ok: false, error: 'hub-endpoint-unavailable' });
3604
+ return { ok: false, error: 'hub-endpoint-unavailable' };
3580
3605
  }
3581
3606
  if (!normalizedPairToken) {
3582
- return Promise.resolve({ ok: false, error: 'hub-pair-token-unavailable' });
3607
+ return { ok: false, error: 'hub-pair-token-unavailable' };
3583
3608
  }
3584
3609
  if (!normalizedDeviceId) {
3585
- return Promise.resolve({ ok: false, error: 'device-id-required' });
3610
+ return { ok: false, error: 'device-id-required' };
3586
3611
  }
3587
3612
  if (!normalizedSlot) {
3588
- return Promise.resolve({ ok: false, error: 'invalid-slot-number' });
3613
+ return { ok: false, error: 'invalid-slot-number' };
3614
+ }
3615
+
3616
+ let socket;
3617
+ try {
3618
+ const credentialStore = createClientDeviceCredentialStore({ deviceId: normalizedDeviceId });
3619
+ socket = await connectSecureDirect({
3620
+ host: endpoint.host,
3621
+ port: endpoint.port,
3622
+ channel: 'control',
3623
+ deviceId: normalizedDeviceId,
3624
+ enrollmentToken: normalizedPairToken,
3625
+ credentialStore,
3626
+ timeoutMs
3627
+ });
3628
+ } catch (error) {
3629
+ return { ok: false, error: error?.code || error?.message || 'hub-slot-secure-connect-failed' };
3589
3630
  }
3590
3631
 
3591
3632
  return new Promise(resolveAssignment => {
3592
- const socket = net.createConnection(endpoint);
3593
3633
  let settled = false;
3594
3634
  let buffer = '';
3635
+ const timer = setTimeout(
3636
+ () => settle({ ok: false, error: 'hub-slot-request-timeout' }),
3637
+ Math.max(250, Math.min(30_000, Number(timeoutMs) || 5000))
3638
+ );
3639
+ timer.unref?.();
3595
3640
  const settle = result => {
3596
3641
  if (settled) return;
3597
3642
  settled = true;
3643
+ clearTimeout(timer);
3598
3644
  socket.removeAllListeners();
3599
3645
  socket.destroy();
3600
3646
  resolveAssignment(result);
3601
3647
  };
3602
3648
  socket.setEncoding('utf8');
3603
- socket.setTimeout(timeoutMs);
3604
- socket.once('connect', () => {
3605
- socket.write(`${JSON.stringify({
3606
- type: 'slot.assign',
3607
- pairToken: normalizedPairToken,
3608
- deviceId: normalizedDeviceId,
3609
- slotNumber: Number(normalizedSlot)
3610
- })}\n`);
3611
- });
3612
3649
  socket.on('data', chunk => {
3613
3650
  buffer += chunk;
3614
3651
  const newlineIndex = buffer.indexOf('\n');
@@ -3624,11 +3661,15 @@ function requestHubSlotAssignment({ manager, pairToken, deviceId, slotNumber, ti
3624
3661
  settle({ ok: false, error: 'invalid-hub-response' });
3625
3662
  }
3626
3663
  });
3627
- socket.once('timeout', () => settle({ ok: false, error: 'hub-slot-request-timeout' }));
3628
3664
  socket.once('error', error => settle({ ok: false, error: error?.message || 'hub-slot-request-failed' }));
3629
3665
  socket.once('close', () => {
3630
- if (!settled) settle({ ok: false, error: 'hub-slot-response-missing' });
3666
+ if (!settled) settle({ ok: false, error: 'hub-slot-response-missing' });
3631
3667
  });
3668
+ socket.write(`${JSON.stringify({
3669
+ type: 'slot.assign',
3670
+ deviceId: normalizedDeviceId,
3671
+ slotNumber: Number(normalizedSlot)
3672
+ })}\n`);
3632
3673
  });
3633
3674
  }
3634
3675
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.222",
3
+ "version": "0.1.224",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,8 @@
19
19
  "scripts": {
20
20
  "check": "node --check bin/client-version.js && node --check bin/livedesk-client.js && node --check bin/livedesk-client-node.js && node --check bin/livedesk-client-update-bootstrap.cjs && node --check bin/livedesk-client-fast.js",
21
21
  "test:version": "node --test tests/client-version.test.mjs",
22
- "pack:dry": "npm pack --dry-run"
22
+ "pack:dry": "npm pack --dry-run",
23
+ "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
23
24
  },
24
25
  "keywords": [
25
26
  "livedesk",
@@ -35,16 +36,16 @@
35
36
  "dependencies": {
36
37
  "@ffmpeg-installer/ffmpeg": "^1.1.0",
37
38
  "ffmpeg-static": "^5.3.0",
38
- "@livedesk/runtime-core": "0.1.1",
39
+ "@livedesk/runtime-core": "0.1.3",
39
40
  "@supabase/supabase-js": "^2.110.0",
40
41
  "node-screenshots": "^0.2.8",
41
42
  "ws": "^8.18.3"
42
43
  },
43
44
  "optionalDependencies": {
44
- "@livedesk/fast-linux-x64": "0.1.427",
45
- "@livedesk/fast-osx-arm64": "0.1.427",
46
- "@livedesk/fast-osx-x64": "0.1.427",
47
- "@livedesk/fast-win-x64": "0.1.427"
45
+ "@livedesk/fast-linux-x64": "0.1.429",
46
+ "@livedesk/fast-osx-arm64": "0.1.429",
47
+ "@livedesk/fast-osx-x64": "0.1.429",
48
+ "@livedesk/fast-win-x64": "0.1.429"
48
49
  },
49
50
  "publishConfig": {
50
51
  "access": "public"
@@ -5,6 +5,7 @@ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync,
5
5
  import { delimiter, dirname, join, parse, posix, resolve } from 'node:path';
6
6
  import os from 'node:os';
7
7
  import { createRuntimeManager, normalizeRuntimeAuthSession, runtimeRoleError, RuntimeState } from '../../../runtime-core/src/index.js';
8
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../../runtime-core/src/os-secret-store.js';
8
9
 
9
10
  const DEFAULT_HOST = '127.0.0.1';
10
11
  const DEFAULT_PORT = 5179;
@@ -20,6 +21,11 @@ const SUPABASE_URL = process.env.LIVEDESK_SUPABASE_URL || 'https://otbyfkjxrkngv
20
21
  const SUPABASE_PUBLISHABLE_KEY = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
21
22
  const CLIENT_STATE_DIR = process.env.LIVEDESK_STATE_DIR || (process.env.LIVEDESK_UNIFIED_RUNTIME === '1' ? join(os.homedir(), '.livedesk') : join(os.homedir(), '.livedesk-client'));
22
23
  const CLIENT_AUTH_PATH = join(CLIENT_STATE_DIR, 'auth.json');
24
+ const CLIENT_REFRESH_SECRET_STORE = createOsSecretStore({
25
+ service: 'LiveDesk',
26
+ account: 'client-refresh-token',
27
+ dataDir: CLIENT_STATE_DIR
28
+ });
23
29
  const DEFAULT_TRUSTED_WEB_ORIGINS = Object.freeze([
24
30
  'https://livedesk.pages.dev',
25
31
  'http://127.0.0.1:5173',
@@ -950,13 +956,35 @@ function readSavedSession() {
950
956
  if (process.env.LIVEDESK_DESKTOP_HOST === '1') return null;
951
957
  try {
952
958
  const raw = JSON.parse(readFileSync(CLIENT_AUTH_PATH, 'utf8'))?.[CLIENT_AUTH_STORAGE_KEY];
953
- const session = typeof raw === 'string' ? JSON.parse(raw) : null;
954
- return session?.access_token ? session : null;
959
+ const session = typeof raw === 'string' ? JSON.parse(raw) : null;
960
+ if (!session?.access_token) return null;
961
+ const plaintextRefreshToken = normalizeString(session.refresh_token, 8192);
962
+ const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
963
+ ? CLIENT_REFRESH_SECRET_STORE.read()
964
+ : '');
965
+ if (!refreshToken) return null;
966
+ if (plaintextRefreshToken) {
967
+ if (!CLIENT_REFRESH_SECRET_STORE.write(plaintextRefreshToken)) {
968
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
969
+ return null;
970
+ }
971
+ const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
972
+ delete migrated.refresh_token;
973
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(migrated) });
974
+ }
975
+ return { ...session, refresh_token: refreshToken };
955
976
  } catch {
956
977
  return null;
957
978
  }
958
979
  }
959
980
 
981
+ function resolveSavedSessionSecret(session) {
982
+ if (!session || typeof session !== 'object') return null;
983
+ const refreshToken = normalizeString(session.refresh_token, 8192)
984
+ || (session.refresh_token_ref === OS_SECRET_REFERENCE ? CLIENT_REFRESH_SECRET_STORE.read() : '');
985
+ return session.access_token && refreshToken ? { ...session, refresh_token: refreshToken } : null;
986
+ }
987
+
960
988
  function writePrivateJsonAtomic(path, value) {
961
989
  mkdirSync(dirname(path), { recursive: true });
962
990
  const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
@@ -974,13 +1002,45 @@ function writePrivateJsonAtomic(path, value) {
974
1002
  function writeSavedSession(session) {
975
1003
  if (!session?.access_token || !session?.refresh_token) return false;
976
1004
  if (process.env.LIVEDESK_DESKTOP_HOST === '1') return true;
977
- writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(session) });
1005
+ if (!CLIENT_REFRESH_SECRET_STORE.write(session.refresh_token)) return false;
1006
+ const persisted = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
1007
+ delete persisted.refresh_token;
1008
+ writePrivateJsonAtomic(CLIENT_AUTH_PATH, { [CLIENT_AUTH_STORAGE_KEY]: JSON.stringify(persisted) });
978
1009
  return true;
979
1010
  }
980
1011
 
981
1012
  function clearSavedSession() {
1013
+ CLIENT_REFRESH_SECRET_STORE.clear();
982
1014
  writePrivateJsonAtomic(CLIENT_AUTH_PATH, {});
983
1015
  }
1016
+
1017
+ export async function revokeClientProviderSession(session, options = {}) {
1018
+ const accessToken = normalizeString(session?.access_token, 8192);
1019
+ if (!accessToken) return { ok: true, skipped: true, reason: 'no-access-token' };
1020
+ try {
1021
+ if (typeof options.revokeProviderSession === 'function') {
1022
+ const result = await options.revokeProviderSession(session);
1023
+ return result && typeof result === 'object' ? result : { ok: result !== false };
1024
+ }
1025
+ const fetchImpl = typeof options.fetchImpl === 'function' ? options.fetchImpl : fetch;
1026
+ const response = await fetchImpl(`${SUPABASE_URL.replace(/\/+$/, '')}/auth/v1/logout?scope=local`, {
1027
+ method: 'POST',
1028
+ headers: {
1029
+ apikey: SUPABASE_PUBLISHABLE_KEY,
1030
+ Authorization: `Bearer ${accessToken}`,
1031
+ Accept: 'application/json'
1032
+ },
1033
+ signal: AbortSignal.timeout(10_000)
1034
+ });
1035
+ if (response.ok) return { ok: true, revoked: true };
1036
+ if ([401, 403, 404].includes(response.status)) {
1037
+ return { ok: true, alreadyInvalid: true, status: response.status };
1038
+ }
1039
+ return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
1040
+ } catch (error) {
1041
+ return { ok: false, error: normalizeString(error?.message || error, 240) || 'provider-session-revoke-failed' };
1042
+ }
1043
+ }
984
1044
 
985
1045
  function readBody(req) {
986
1046
  return new Promise((resolveBody, reject) => {
@@ -1114,12 +1174,13 @@ export function createClientRuntimeServer(options = {}) {
1114
1174
  const trustedWebOrigins = createTrustedWebOrigins(port);
1115
1175
  const webDist = resolve(String(options.webDist || process.env.LIVEDESK_WEB_DIST || '').trim() || join(process.cwd(), 'apps', 'web', 'dist'));
1116
1176
  const deviceId = normalizeString(options.deviceId || process.env.LIVEDESK_DEVICE_ID, 160);
1117
- const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1177
+ const deviceName = normalizeString(options.deviceName || os.hostname(), 160) || os.hostname();
1118
1178
  const appVersion = normalizeString(options.appVersion || process.env.LIVEDESK_NPM_LAUNCHER_VERSION, 80) || 'dev';
1179
+ const providedSavedSession = resolveSavedSessionSecret(options.savedSession);
1119
1180
  const savedSession = options.loadSavedSession === false
1120
1181
  ? null
1121
- : options.savedSession?.refresh_token
1122
- ? options.savedSession
1182
+ : providedSavedSession
1183
+ ? providedSavedSession
1123
1184
  : readSavedSession();
1124
1185
  const savedSessionPersisted = Boolean(savedSession?.refresh_token);
1125
1186
  const savedAccountProfile = readClientAccountProfile(savedSession);
@@ -1635,8 +1696,13 @@ export function createClientRuntimeServer(options = {}) {
1635
1696
  complete({ type: 'google', session }, 'Signed in. Finding the LiveDesk Hub.');
1636
1697
  respondJson(200, { ok: true, authenticated: true, persisted: true, role: 'client' });
1637
1698
  return;
1638
- }
1639
- if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1699
+ }
1700
+ if (pathname === '/api/auth/session' && req.method === 'DELETE') {
1701
+ const activeSession = lastChoice?.session || savedSession || readSavedSession();
1702
+ const providerLogout = await revokeClientProviderSession(activeSession, {
1703
+ revokeProviderSession: options.revokeProviderSession,
1704
+ fetchImpl: options.fetchImpl
1705
+ });
1640
1706
  clearSavedSession();
1641
1707
  loggedOut = true;
1642
1708
  completed = false;
@@ -1653,9 +1719,15 @@ export function createClientRuntimeServer(options = {}) {
1653
1719
  name: '',
1654
1720
  avatarUrl: ''
1655
1721
  };
1656
- runtime.setAuthenticated(false);
1657
- respondJson(200, { ok: true, authenticated: false, role: 'client' });
1658
- return;
1722
+ runtime.setAuthenticated(false);
1723
+ respondJson(providerLogout.ok ? 200 : 502, {
1724
+ ok: providerLogout.ok,
1725
+ authenticated: false,
1726
+ localCleared: true,
1727
+ role: 'client',
1728
+ providerLogout
1729
+ });
1730
+ return;
1659
1731
  }
1660
1732
  if (pathname === '/api/runtime/restart' && req.method === 'POST') {
1661
1733
  runtime.emit('runtime.restart.requested', { role: 'client' });
@@ -0,0 +1,190 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { decodeCanonicalBase64Url } from '@livedesk/runtime-core';
6
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '@livedesk/runtime-core/os-secret-store';
7
+
8
+ const STORE_VERSION = 1;
9
+
10
+ function credentialError(code) {
11
+ const error = new Error(code);
12
+ error.code = code;
13
+ return error;
14
+ }
15
+
16
+ function clean(value, maximum = 512) {
17
+ return String(value || '').replace(/[\0\r\n]/g, '').trim().slice(0, maximum);
18
+ }
19
+
20
+ function atomicPrivateJson(filePath, value) {
21
+ mkdirSync(path.dirname(filePath), { recursive: true });
22
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
23
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
24
+ renameSync(temporary, filePath);
25
+ }
26
+
27
+ function loadJson(filePath) {
28
+ try {
29
+ const value = JSON.parse(readFileSync(filePath, 'utf8'));
30
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function requirePrivateKey(value) {
37
+ const der = decodeCanonicalBase64Url(value, 100, 512);
38
+ let key;
39
+ try {
40
+ key = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
41
+ } catch {
42
+ throw credentialError('device-private-key-invalid');
43
+ }
44
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
45
+ throw credentialError('device-private-key-invalid');
46
+ }
47
+ return key;
48
+ }
49
+
50
+ function parseCredential(credential) {
51
+ const text = String(credential || '');
52
+ const parts = text.split('.');
53
+ if (parts.length !== 2 || text.length > 8192) throw credentialError('device-credential-invalid');
54
+ const payloadBytes = decodeCanonicalBase64Url(parts[0], 32, 4096);
55
+ const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
56
+ let payload;
57
+ try {
58
+ payload = JSON.parse(payloadBytes.toString('utf8'));
59
+ } catch {
60
+ throw credentialError('device-credential-invalid');
61
+ }
62
+ if (!payload || Number(payload.version) !== 1) throw credentialError('device-credential-invalid');
63
+ const hubPublicDer = decodeCanonicalBase64Url(payload.hubPublicKey, 80, 160);
64
+ let hubPublicKey;
65
+ try {
66
+ hubPublicKey = crypto.createPublicKey({ key: hubPublicDer, format: 'der', type: 'spki' });
67
+ } catch {
68
+ throw credentialError('device-credential-invalid');
69
+ }
70
+ if (hubPublicKey.asymmetricKeyType !== 'ec'
71
+ || hubPublicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1'
72
+ || !crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), { key: hubPublicKey, dsaEncoding: 'ieee-p1363' }, signature)) {
73
+ throw credentialError('device-credential-signature-invalid');
74
+ }
75
+ const normalized = {
76
+ serial: clean(payload.serial, 128),
77
+ accountId: clean(payload.accountId, 128),
78
+ hubId: clean(payload.hubId, 128),
79
+ deviceId: clean(payload.deviceId, 128),
80
+ devicePublicKey: clean(payload.devicePublicKey, 512),
81
+ hubPublicKey: hubPublicDer.toString('base64url'),
82
+ issuedAt: Number(payload.issuedAt),
83
+ expiresAt: Number(payload.expiresAt)
84
+ };
85
+ if (!normalized.serial || !normalized.accountId || !normalized.hubId || !normalized.deviceId
86
+ || !Number.isSafeInteger(normalized.issuedAt) || !Number.isSafeInteger(normalized.expiresAt)
87
+ || normalized.expiresAt <= Date.now()) {
88
+ throw credentialError('device-credential-expired');
89
+ }
90
+ return { text, payloadText: parts[0], payload: normalized, hubPublicKey };
91
+ }
92
+
93
+ export function createClientDeviceCredentialStore({
94
+ filePath = String(process.env.LIVEDESK_DEVICE_CREDENTIAL_PATH || '').trim()
95
+ || path.join(os.homedir(), '.livedesk-client', 'device-credential-v1.json'),
96
+ deviceId
97
+ } = {}) {
98
+ const normalizedDeviceId = clean(deviceId, 128);
99
+ if (!normalizedDeviceId) throw credentialError('device-id-required');
100
+ const privateKeyStore = createOsSecretStore({
101
+ service: 'LiveDesk',
102
+ account: `client-device-private-key:${normalizedDeviceId}`,
103
+ dataDir: path.dirname(filePath)
104
+ });
105
+ let state = loadJson(filePath);
106
+ let privateKey;
107
+ let publicKey;
108
+ if (state) {
109
+ if (Number(state.version) !== STORE_VERSION || state.deviceId !== normalizedDeviceId) throw credentialError('device-key-state-invalid');
110
+ const plaintextPrivateKey = clean(state.privateKey, 1024);
111
+ if (plaintextPrivateKey) {
112
+ if (!privateKeyStore.write(plaintextPrivateKey)) throw credentialError('device-private-key-migration-failed');
113
+ state = { ...state, privateKeyRef: OS_SECRET_REFERENCE, updatedAt: new Date().toISOString() };
114
+ delete state.privateKey;
115
+ atomicPrivateJson(filePath, state);
116
+ }
117
+ if (state.privateKeyRef !== OS_SECRET_REFERENCE) throw credentialError('device-private-key-reference-invalid');
118
+ const privateKeyText = privateKeyStore.read();
119
+ if (!privateKeyText) throw credentialError('device-private-key-secure-store-unavailable');
120
+ privateKey = requirePrivateKey(privateKeyText);
121
+ publicKey = crypto.createPublicKey(privateKey);
122
+ const publicText = publicKey.export({ format: 'der', type: 'spki' }).toString('base64url');
123
+ if (publicText !== state.publicKey) throw credentialError('device-key-mismatch');
124
+ } else {
125
+ const generated = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
126
+ privateKey = generated.privateKey;
127
+ publicKey = generated.publicKey;
128
+ const privateKeyText = privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url');
129
+ if (!privateKeyStore.write(privateKeyText)) throw credentialError('device-private-key-secure-store-unavailable');
130
+ state = {
131
+ version: STORE_VERSION,
132
+ deviceId: normalizedDeviceId,
133
+ publicKey: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'),
134
+ privateKeyRef: OS_SECRET_REFERENCE,
135
+ credential: '',
136
+ createdAt: new Date().toISOString(),
137
+ updatedAt: new Date().toISOString()
138
+ };
139
+ atomicPrivateJson(filePath, state);
140
+ }
141
+
142
+ function saveCredential(credential) {
143
+ const parsed = parseCredential(credential);
144
+ if (parsed.payload.deviceId !== normalizedDeviceId || parsed.payload.devicePublicKey !== state.publicKey) {
145
+ throw credentialError('device-credential-binding-invalid');
146
+ }
147
+ state.credential = parsed.text;
148
+ state.accountId = parsed.payload.accountId;
149
+ state.hubId = parsed.payload.hubId;
150
+ state.updatedAt = new Date().toISOString();
151
+ atomicPrivateJson(filePath, state);
152
+ return parsed;
153
+ }
154
+
155
+ function readCredential() {
156
+ if (!state.credential) return null;
157
+ try {
158
+ const parsed = parseCredential(state.credential);
159
+ if (parsed.payload.deviceId !== normalizedDeviceId || parsed.payload.devicePublicKey !== state.publicKey) return null;
160
+ return parsed;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
165
+
166
+ function clearCredential() {
167
+ state.credential = '';
168
+ state.accountId = '';
169
+ state.hubId = '';
170
+ state.updatedAt = new Date().toISOString();
171
+ atomicPrivateJson(filePath, state);
172
+ }
173
+
174
+ return Object.freeze({
175
+ filePath,
176
+ deviceId: normalizedDeviceId,
177
+ publicKey: state.publicKey,
178
+ privateKey,
179
+ sign(message) {
180
+ return crypto.sign('sha256', Buffer.from(String(message || ''), 'utf8'), {
181
+ key: privateKey,
182
+ dsaEncoding: 'ieee-p1363'
183
+ }).toString('base64url');
184
+ },
185
+ readCredential,
186
+ saveCredential,
187
+ clearCredential,
188
+ parseCredential
189
+ });
190
+ }
@@ -0,0 +1,224 @@
1
+ import crypto from 'node:crypto';
2
+ import net from 'node:net';
3
+ import {
4
+ SECURE_SESSION_MAX_CLOCK_SKEW_MS,
5
+ SECURE_SESSION_MAX_HANDSHAKE_BYTES,
6
+ SECURE_SESSION_PROTOCOL,
7
+ SecureRecordSocket,
8
+ decodeCanonicalBase64Url,
9
+ deriveSecureSessionKey,
10
+ enrollmentProof,
11
+ fixedTimeBase64UrlEqual,
12
+ normalizeSecureChannel,
13
+ secureClientTranscript,
14
+ secureServerTranscript
15
+ } from '@livedesk/runtime-core';
16
+
17
+ const CONNECT_TIMEOUT_MS = 5_000;
18
+
19
+ function secureError(code) {
20
+ const error = new Error(code);
21
+ error.code = code;
22
+ return error;
23
+ }
24
+
25
+ function clean(value, maximum = 256) {
26
+ return String(value || '').replace(/[\0\r\n]/g, '').trim().slice(0, maximum);
27
+ }
28
+
29
+ function requireP256PublicKey(value) {
30
+ const der = decodeCanonicalBase64Url(value, 80, 160);
31
+ let key;
32
+ try {
33
+ key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
34
+ } catch {
35
+ throw secureError('secure-public-key-invalid');
36
+ }
37
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
38
+ throw secureError('secure-public-key-invalid');
39
+ }
40
+ return key;
41
+ }
42
+
43
+ async function connectRaw(host, port, timeoutMs) {
44
+ return await new Promise((resolve, reject) => {
45
+ const socket = net.createConnection({ host, port });
46
+ const timer = setTimeout(() => {
47
+ socket.destroy();
48
+ reject(secureError('secure-direct-connect-timeout'));
49
+ }, timeoutMs);
50
+ timer.unref?.();
51
+ socket.once('connect', () => {
52
+ clearTimeout(timer);
53
+ socket.setNoDelay(true);
54
+ resolve(socket);
55
+ });
56
+ socket.once('error', error => {
57
+ clearTimeout(timer);
58
+ reject(error);
59
+ });
60
+ });
61
+ }
62
+
63
+ async function readHandshakeLine(socket, timeoutMs) {
64
+ return await new Promise((resolve, reject) => {
65
+ let chunks = [];
66
+ let bytes = 0;
67
+ const timer = setTimeout(() => finish(secureError('secure-server-hello-timeout')), timeoutMs);
68
+ timer.unref?.();
69
+ const cleanup = () => {
70
+ clearTimeout(timer);
71
+ socket.removeListener('data', onData);
72
+ socket.removeListener('error', onError);
73
+ socket.removeListener('close', onClose);
74
+ };
75
+ const finish = (error, value) => {
76
+ cleanup();
77
+ if (error) reject(error);
78
+ else resolve(value);
79
+ };
80
+ const onError = error => finish(error);
81
+ const onClose = () => finish(secureError('secure-server-hello-closed'));
82
+ const onData = chunk => {
83
+ const incoming = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk || []);
84
+ bytes += incoming.length;
85
+ if (bytes > SECURE_SESSION_MAX_HANDSHAKE_BYTES) {
86
+ finish(secureError('secure-server-hello-too-large'));
87
+ return;
88
+ }
89
+ chunks.push(incoming);
90
+ const combined = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, bytes);
91
+ const newline = combined.indexOf(0x0a);
92
+ if (newline < 0) return;
93
+ let message;
94
+ try {
95
+ message = JSON.parse(combined.subarray(0, newline).toString('utf8'));
96
+ } catch {
97
+ finish(secureError('secure-server-hello-invalid'));
98
+ return;
99
+ }
100
+ finish(null, { message, remainder: combined.subarray(newline + 1) });
101
+ };
102
+ socket.on('data', onData);
103
+ socket.once('error', onError);
104
+ socket.once('close', onClose);
105
+ });
106
+ }
107
+
108
+ export async function connectSecureDirect({
109
+ host,
110
+ port,
111
+ channel = 'control',
112
+ deviceId,
113
+ enrollmentToken,
114
+ credentialStore,
115
+ timeoutMs = CONNECT_TIMEOUT_MS
116
+ } = {}) {
117
+ const normalizedChannel = normalizeSecureChannel(channel);
118
+ const normalizedDeviceId = clean(deviceId, 128);
119
+ if (!normalizedDeviceId || !credentialStore?.privateKey || credentialStore.deviceId !== normalizedDeviceId) {
120
+ throw secureError('device-credential-store-required');
121
+ }
122
+ const existingCredential = credentialStore.readCredential();
123
+ const mode = existingCredential ? 'resume' : 'enroll';
124
+ if (mode === 'enroll' && normalizedChannel !== 'control') throw secureError('secure-enrollment-control-channel-required');
125
+ const generated = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
126
+ const hello = {
127
+ type: 'secure.client-hello',
128
+ protocol: SECURE_SESSION_PROTOCOL,
129
+ mode,
130
+ channel: normalizedChannel,
131
+ timestamp: Date.now(),
132
+ nonce: crypto.randomBytes(16).toString('base64url'),
133
+ deviceId: normalizedDeviceId,
134
+ devicePublicKey: credentialStore.publicKey,
135
+ clientEphemeralPublicKey: generated.publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'),
136
+ ...(existingCredential ? { credential: existingCredential.text } : {})
137
+ };
138
+ const clientTranscript = secureClientTranscript(hello);
139
+ hello.deviceSignature = credentialStore.sign(clientTranscript);
140
+ if (mode === 'enroll') hello.enrollmentProof = enrollmentProof(enrollmentToken, clientTranscript);
141
+
142
+ const socket = await connectRaw(host, port, Math.max(250, Math.min(30_000, Number(timeoutMs) || CONNECT_TIMEOUT_MS)));
143
+ try {
144
+ socket.write(`${JSON.stringify(hello)}\n`);
145
+ const { message: response, remainder } = await readHandshakeLine(socket, timeoutMs);
146
+ if (response?.type === 'secure.error') throw secureError(clean(response.error, 100) || 'secure-handshake-rejected');
147
+ if (response?.type !== 'secure.server-hello'
148
+ || response.protocol !== SECURE_SESSION_PROTOCOL
149
+ || response.channel !== normalizedChannel
150
+ || response.deviceId !== normalizedDeviceId) {
151
+ throw secureError('secure-server-hello-invalid');
152
+ }
153
+ if (!Number.isSafeInteger(Number(response.timestamp))
154
+ || Math.abs(Date.now() - Number(response.timestamp)) > SECURE_SESSION_MAX_CLOCK_SKEW_MS) {
155
+ throw secureError('secure-server-timestamp-invalid');
156
+ }
157
+ decodeCanonicalBase64Url(response.sessionId, 16, 32);
158
+ decodeCanonicalBase64Url(response.serverNonce, 16, 32);
159
+ const serverEphemeralPublicKey = requireP256PublicKey(response.serverEphemeralPublicKey);
160
+ const parsedCredential = credentialStore.parseCredential(response.credential);
161
+ if (parsedCredential.payload.deviceId !== normalizedDeviceId
162
+ || parsedCredential.payload.devicePublicKey !== credentialStore.publicKey
163
+ || response.hubId !== parsedCredential.payload.hubId
164
+ || response.accountId !== parsedCredential.payload.accountId) {
165
+ throw secureError('device-credential-binding-invalid');
166
+ }
167
+ if (existingCredential
168
+ && (parsedCredential.payload.hubId !== existingCredential.payload.hubId
169
+ || parsedCredential.payload.accountId !== existingCredential.payload.accountId
170
+ || parsedCredential.payload.hubPublicKey !== existingCredential.payload.hubPublicKey)) {
171
+ throw secureError('secure-hub-identity-changed');
172
+ }
173
+ const serverTranscript = secureServerTranscript(response, clientTranscript);
174
+ if (mode === 'enroll') {
175
+ const expectedServerProof = enrollmentProof(enrollmentToken, serverTranscript);
176
+ if (!fixedTimeBase64UrlEqual(response.enrollmentServerProof, expectedServerProof, 32)) {
177
+ throw secureError('secure-enrollment-server-proof-invalid');
178
+ }
179
+ }
180
+ const hubSignature = decodeCanonicalBase64Url(response.hubSignature, 64, 64);
181
+ if (!crypto.verify('sha256', Buffer.from(serverTranscript, 'utf8'), {
182
+ key: parsedCredential.hubPublicKey,
183
+ dsaEncoding: 'ieee-p1363'
184
+ }, hubSignature)) {
185
+ throw secureError('secure-hub-signature-invalid');
186
+ }
187
+ const sharedSecret = crypto.diffieHellman({ privateKey: generated.privateKey, publicKey: serverEphemeralPublicKey });
188
+ let sessionKey;
189
+ try {
190
+ sessionKey = deriveSecureSessionKey({
191
+ sharedSecret,
192
+ clientTranscript,
193
+ serverTranscript,
194
+ sessionId: response.sessionId,
195
+ channel: normalizedChannel
196
+ });
197
+ } finally {
198
+ sharedSecret.fill(0);
199
+ }
200
+ if (!existingCredential || existingCredential.text !== parsedCredential.text) credentialStore.saveCredential(parsedCredential.text);
201
+ const secureSocket = new SecureRecordSocket(socket, {
202
+ sessionKey,
203
+ sessionId: response.sessionId,
204
+ channel: normalizedChannel,
205
+ role: 'client',
206
+ securityContext: {
207
+ protocol: SECURE_SESSION_PROTOCOL,
208
+ accountId: parsedCredential.payload.accountId,
209
+ hubId: parsedCredential.payload.hubId,
210
+ deviceId: normalizedDeviceId,
211
+ credentialSerial: parsedCredential.payload.serial,
212
+ authenticated: true,
213
+ encrypted: true
214
+ }
215
+ });
216
+ sessionKey.fill(0);
217
+ secureSocket.__liveDeskDirectSecure = true;
218
+ if (remainder.length > 0) secureSocket.feedEncrypted(remainder);
219
+ return secureSocket;
220
+ } catch (error) {
221
+ socket.destroy();
222
+ throw error;
223
+ }
224
+ }