@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 =
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
748
|
-
|
|
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
|
-
|
|
772
|
-
await fs.
|
|
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(['
|
|
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
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
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
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
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;
|
package/bin/livedesk-client.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
3604
|
+
return { ok: false, error: 'hub-endpoint-unavailable' };
|
|
3580
3605
|
}
|
|
3581
3606
|
if (!normalizedPairToken) {
|
|
3582
|
-
return
|
|
3607
|
+
return { ok: false, error: 'hub-pair-token-unavailable' };
|
|
3583
3608
|
}
|
|
3584
3609
|
if (!normalizedDeviceId) {
|
|
3585
|
-
return
|
|
3610
|
+
return { ok: false, error: 'device-id-required' };
|
|
3586
3611
|
}
|
|
3587
3612
|
if (!normalizedSlot) {
|
|
3588
|
-
return
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
45
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
47
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
1122
|
-
?
|
|
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(
|
|
1658
|
-
|
|
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
|
+
}
|