@livedesk/client 0.1.231 → 0.1.233
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.
- package/bin/livedesk-client.js +119 -44
- package/package.json +53 -53
- package/src/runtime/fast-runtime-repair.js +300 -0
- package/src/security/device-credential-store.js +219 -190
package/bin/livedesk-client.js
CHANGED
|
@@ -26,6 +26,12 @@ import {
|
|
|
26
26
|
inspectLinuxVideoAcceleration,
|
|
27
27
|
installLinuxVideoAcceleration
|
|
28
28
|
} from '../src/runtime/linux-video-acceleration.js';
|
|
29
|
+
import {
|
|
30
|
+
ensureRepairedFastRuntime,
|
|
31
|
+
inspectFastRuntimePackage,
|
|
32
|
+
inspectRepairedFastRuntime,
|
|
33
|
+
resolveFastPlatformSpec
|
|
34
|
+
} from '../src/runtime/fast-runtime-repair.js';
|
|
29
35
|
import { normalizeRuntimeAuthSession } from '../../runtime-core/src/auth-session.js';
|
|
30
36
|
import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
|
|
31
37
|
import { startRoleTransitionSupervisor } from '../../runtime-core/src/role-transition-supervisor.js';
|
|
@@ -330,8 +336,8 @@ Options:
|
|
|
330
336
|
--version Show the agent version.
|
|
331
337
|
--help Show this help.
|
|
332
338
|
|
|
333
|
-
Auto uses C# RemoteFast
|
|
334
|
-
|
|
339
|
+
Auto uses the verified C# RemoteFast screen engine on supported desktop platforms.
|
|
340
|
+
If an npx cache omitted it, LiveDesk restores the exact pinned platform package before connecting.
|
|
335
341
|
Enable Windows auto-start from the connection page when this client signs in.
|
|
336
342
|
`.trimStart());
|
|
337
343
|
}
|
|
@@ -340,6 +346,14 @@ function readPackageVersion() {
|
|
|
340
346
|
return readClientPackageVersion();
|
|
341
347
|
}
|
|
342
348
|
|
|
349
|
+
function readClientPackageManifest() {
|
|
350
|
+
try {
|
|
351
|
+
return JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
343
357
|
function isTruthy(value) {
|
|
344
358
|
return /^(1|true|yes|on)$/i.test(String(value || '').trim());
|
|
345
359
|
}
|
|
@@ -4667,42 +4681,67 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
|
|
|
4667
4681
|
};
|
|
4668
4682
|
}
|
|
4669
4683
|
|
|
4684
|
+
function getFastRuntimeSpec() {
|
|
4685
|
+
return resolveFastPlatformSpec({
|
|
4686
|
+
platform: os.platform(),
|
|
4687
|
+
arch: os.arch(),
|
|
4688
|
+
manifest: readClientPackageManifest()
|
|
4689
|
+
});
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4670
4692
|
function getFastRuntime() {
|
|
4671
|
-
const
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4676
|
-
|
|
4677
|
-
|
|
4678
|
-
|
|
4679
|
-
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
4683
|
-
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
dll: join(packageRoot, 'fast', rid, 'livedesk-client-fast.dll')
|
|
4689
|
-
};
|
|
4690
|
-
}
|
|
4693
|
+
const spec = getFastRuntimeSpec();
|
|
4694
|
+
if (!spec) return null;
|
|
4695
|
+
try {
|
|
4696
|
+
const packagePath = require.resolve(`${spec.packageName}/package.json`);
|
|
4697
|
+
const installed = inspectFastRuntimePackage(dirname(packagePath), spec);
|
|
4698
|
+
if (installed) return { ...installed, source: 'optional-dependency' };
|
|
4699
|
+
} catch {
|
|
4700
|
+
// A cached npx install can be complete except for its optional platform package.
|
|
4701
|
+
}
|
|
4702
|
+
const bundledFastRoot = join(packageRoot, 'fast', spec.rid);
|
|
4703
|
+
const bundled = {
|
|
4704
|
+
rid: spec.rid,
|
|
4705
|
+
packageName: '',
|
|
4706
|
+
packageVersion: spec.version,
|
|
4707
|
+
executable: join(bundledFastRoot, spec.executableName),
|
|
4708
|
+
dll: join(bundledFastRoot, 'livedesk-client-fast.dll'),
|
|
4709
|
+
source: 'bundled-development-runtime'
|
|
4691
4710
|
};
|
|
4692
|
-
if (
|
|
4693
|
-
|
|
4694
|
-
}
|
|
4695
|
-
|
|
4696
|
-
|
|
4697
|
-
|
|
4698
|
-
|
|
4699
|
-
|
|
4700
|
-
|
|
4701
|
-
if (
|
|
4702
|
-
|
|
4703
|
-
}
|
|
4704
|
-
|
|
4705
|
-
|
|
4711
|
+
if (hasFastExecutable(bundled) || hasFastDll(bundled)) return bundled;
|
|
4712
|
+
const repaired = inspectRepairedFastRuntime(UNIFIED_CLIENT_STATE_DIR, spec);
|
|
4713
|
+
if (repaired) return { ...repaired, source: 'repaired-runtime-cache' };
|
|
4714
|
+
return { ...bundled, packageName: spec.packageName, source: 'missing' };
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
async function repairFastRuntimeIfMissing(runtime, connectionPage = null) {
|
|
4718
|
+
if (hasFastExecutable(runtime) || hasFastDll(runtime)) return runtime;
|
|
4719
|
+
const spec = getFastRuntimeSpec();
|
|
4720
|
+
if (!spec) return runtime;
|
|
4721
|
+
const label = `${spec.packageName}@${spec.version}`;
|
|
4722
|
+
console.warn(`[LiveDesk Client] RemoteFast ${spec.rid} is missing from the npx installation. Repairing ${label}.`);
|
|
4723
|
+
connectionPage?.update({
|
|
4724
|
+
message: `Installing the verified ${spec.rid} screen engine. This is needed only when the npx cache omitted it.`,
|
|
4725
|
+
agent: {
|
|
4726
|
+
requestedEngine: 'fast',
|
|
4727
|
+
engine: 'fast',
|
|
4728
|
+
state: 'repairing',
|
|
4729
|
+
runtimeId: spec.rid,
|
|
4730
|
+
command: label,
|
|
4731
|
+
args: []
|
|
4732
|
+
}
|
|
4733
|
+
});
|
|
4734
|
+
const repaired = await ensureRepairedFastRuntime({
|
|
4735
|
+
stateDir: UNIFIED_CLIENT_STATE_DIR,
|
|
4736
|
+
spec,
|
|
4737
|
+
nodeExecutable: process.execPath,
|
|
4738
|
+
npmExecPath: process.env.npm_execpath || process.env.NPM_EXECPATH,
|
|
4739
|
+
npmExecutable: process.env.LIVEDESK_NPM_EXECUTABLE,
|
|
4740
|
+
env: process.env
|
|
4741
|
+
});
|
|
4742
|
+
clearFastPreflightCache();
|
|
4743
|
+
console.log(`[LiveDesk Client] Repaired ${label} in the private LiveDesk runtime cache.`);
|
|
4744
|
+
return { ...repaired, source: 'repaired-runtime-cache' };
|
|
4706
4745
|
}
|
|
4707
4746
|
|
|
4708
4747
|
function summarizeText(value, maxLength = 600) {
|
|
@@ -5356,7 +5395,31 @@ export async function runClientRuntime(argv = process.argv.slice(2), runtimeOpti
|
|
|
5356
5395
|
connectionPage?.close?.();
|
|
5357
5396
|
process.exit(0);
|
|
5358
5397
|
}
|
|
5359
|
-
|
|
5398
|
+
let fastRuntime = getFastRuntime();
|
|
5399
|
+
const fastSpec = getFastRuntimeSpec();
|
|
5400
|
+
let fastRepairError = null;
|
|
5401
|
+
if (prepared.engine !== 'node'
|
|
5402
|
+
&& fastSpec
|
|
5403
|
+
&& !hasFastExecutable(fastRuntime)
|
|
5404
|
+
&& !hasFastDll(fastRuntime)) {
|
|
5405
|
+
try {
|
|
5406
|
+
fastRuntime = await repairFastRuntimeIfMissing(fastRuntime, prepared.connectionPage);
|
|
5407
|
+
} catch (error) {
|
|
5408
|
+
fastRepairError = error;
|
|
5409
|
+
const message = summarizeText(error?.message || error, 600);
|
|
5410
|
+
console.error(`[LiveDesk Client] RemoteFast repair failed: ${message}`);
|
|
5411
|
+
prepared.connectionPage?.update({
|
|
5412
|
+
agent: {
|
|
5413
|
+
requestedEngine: prepared.engine,
|
|
5414
|
+
engine: 'fast',
|
|
5415
|
+
state: 'failed',
|
|
5416
|
+
runtimeId: fastSpec.rid,
|
|
5417
|
+
error: message
|
|
5418
|
+
},
|
|
5419
|
+
message: 'The screen engine could not be restored. LiveDesk did not start a reduced Node connection.'
|
|
5420
|
+
});
|
|
5421
|
+
}
|
|
5422
|
+
}
|
|
5360
5423
|
const fastRequired = requiresFastTransport(prepared);
|
|
5361
5424
|
if (fastRequired && prepared.engine === 'node') {
|
|
5362
5425
|
throw new Error(
|
|
@@ -5407,13 +5470,25 @@ export async function runClientRuntime(argv = process.argv.slice(2), runtimeOpti
|
|
|
5407
5470
|
state: 'running'
|
|
5408
5471
|
});
|
|
5409
5472
|
});
|
|
5410
|
-
} else if (prepared.engine === 'fast'
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5473
|
+
} else if (prepared.engine === 'fast'
|
|
5474
|
+
|| fastRequired
|
|
5475
|
+
|| fastRepairError
|
|
5476
|
+
|| (fastSpec && fastRuntime?.source === 'missing')) {
|
|
5477
|
+
const repairDetail = fastRepairError
|
|
5478
|
+
? ` Automatic repair failed: ${summarizeText(fastRepairError?.message || fastRepairError, 500)}`
|
|
5479
|
+
: '';
|
|
5480
|
+
const unavailableMessage = `C# RemoteFast is unavailable: ${fastLaunch.reason}.${repairDetail}`;
|
|
5481
|
+
console.error(`${unavailableMessage} The reduced Node connection was not started because it cannot provide the required LiveDesk screen stream.`);
|
|
5482
|
+
prepared.connectionPage?.update({
|
|
5483
|
+
agent: {
|
|
5484
|
+
requestedEngine: prepared.engine,
|
|
5485
|
+
engine: 'fast',
|
|
5486
|
+
state: 'failed',
|
|
5487
|
+
runtimeId: fastRuntime?.rid || fastSpec?.rid || '',
|
|
5488
|
+
error: unavailableMessage
|
|
5489
|
+
},
|
|
5490
|
+
message: 'LiveDesk could not start the verified screen engine. Restart the same @latest command after network access is restored.'
|
|
5491
|
+
});
|
|
5417
5492
|
prepared.connectionPage?.close?.();
|
|
5418
5493
|
process.exit(2);
|
|
5419
5494
|
} else {
|
package/package.json
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "LiveDesk local remote client",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"client": "bin/livedesk-client.js",
|
|
8
|
-
"livedesk-client": "bin/livedesk-client.js",
|
|
9
|
-
"livedesk-client-node": "bin/livedesk-client-node.js",
|
|
10
|
-
"livedesk-client-fast": "bin/livedesk-client-fast.js"
|
|
11
|
-
},
|
|
12
|
-
"files": [
|
|
13
|
-
"bin/",
|
|
14
|
-
"src/",
|
|
15
|
-
"tests/",
|
|
16
|
-
"README.md",
|
|
17
|
-
"THIRD_PARTY_NOTICES.md"
|
|
18
|
-
],
|
|
19
|
-
"scripts": {
|
|
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
|
-
"test:version": "node --test tests/client-version.test.mjs",
|
|
22
|
-
"pack:dry": "npm pack --dry-run",
|
|
23
|
-
"prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
|
|
24
|
-
},
|
|
25
|
-
"keywords": [
|
|
26
|
-
"livedesk",
|
|
27
|
-
"remote",
|
|
28
|
-
"agent",
|
|
29
|
-
"local",
|
|
30
|
-
"desktop"
|
|
31
|
-
],
|
|
32
|
-
"license": "MIT",
|
|
33
|
-
"engines": {
|
|
34
|
-
"node": ">=20"
|
|
35
|
-
},
|
|
36
|
-
"dependencies": {
|
|
37
|
-
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
38
|
-
"ffmpeg-static": "^5.3.0",
|
|
39
|
-
"@livedesk/runtime-core": "0.1.4",
|
|
40
|
-
"@supabase/supabase-js": "^2.110.0",
|
|
41
|
-
"node-screenshots": "^0.2.8",
|
|
42
|
-
"ws": "^8.18.3"
|
|
43
|
-
},
|
|
44
|
-
"optionalDependencies": {
|
|
45
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
49
|
-
},
|
|
50
|
-
"publishConfig": {
|
|
51
|
-
"access": "public"
|
|
52
|
-
}
|
|
53
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@livedesk/client",
|
|
3
|
+
"version": "0.1.233",
|
|
4
|
+
"description": "LiveDesk local remote client",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"client": "bin/livedesk-client.js",
|
|
8
|
+
"livedesk-client": "bin/livedesk-client.js",
|
|
9
|
+
"livedesk-client-node": "bin/livedesk-client-node.js",
|
|
10
|
+
"livedesk-client-fast": "bin/livedesk-client-fast.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/",
|
|
15
|
+
"tests/",
|
|
16
|
+
"README.md",
|
|
17
|
+
"THIRD_PARTY_NOTICES.md"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
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
|
+
"test:version": "node --test tests/client-version.test.mjs",
|
|
22
|
+
"pack:dry": "npm pack --dry-run",
|
|
23
|
+
"prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"livedesk",
|
|
27
|
+
"remote",
|
|
28
|
+
"agent",
|
|
29
|
+
"local",
|
|
30
|
+
"desktop"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
|
38
|
+
"ffmpeg-static": "^5.3.0",
|
|
39
|
+
"@livedesk/runtime-core": "0.1.4",
|
|
40
|
+
"@supabase/supabase-js": "^2.110.0",
|
|
41
|
+
"node-screenshots": "^0.2.8",
|
|
42
|
+
"ws": "^8.18.3"
|
|
43
|
+
},
|
|
44
|
+
"optionalDependencies": {
|
|
45
|
+
"@livedesk/fast-linux-x64": "0.1.431",
|
|
46
|
+
"@livedesk/fast-osx-arm64": "0.1.431",
|
|
47
|
+
"@livedesk/fast-osx-x64": "0.1.431",
|
|
48
|
+
"@livedesk/fast-win-x64": "0.1.431"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
15
|
+
const LOCK_POLL_MS = 200;
|
|
16
|
+
const LOCK_STALE_MS = 10 * 60_000;
|
|
17
|
+
const MAX_OUTPUT_CHARS = 64 * 1024;
|
|
18
|
+
|
|
19
|
+
const PLATFORM_SPECS = Object.freeze({
|
|
20
|
+
'win32:x64': Object.freeze({
|
|
21
|
+
packageName: '@livedesk/fast-win-x64',
|
|
22
|
+
rid: 'win-x64',
|
|
23
|
+
executableName: 'livedesk-client-fast.exe'
|
|
24
|
+
}),
|
|
25
|
+
'linux:x64': Object.freeze({
|
|
26
|
+
packageName: '@livedesk/fast-linux-x64',
|
|
27
|
+
rid: 'linux-x64',
|
|
28
|
+
executableName: 'livedesk-client-fast'
|
|
29
|
+
}),
|
|
30
|
+
'darwin:x64': Object.freeze({
|
|
31
|
+
packageName: '@livedesk/fast-osx-x64',
|
|
32
|
+
rid: 'osx-x64',
|
|
33
|
+
executableName: 'livedesk-client-fast'
|
|
34
|
+
}),
|
|
35
|
+
'darwin:arm64': Object.freeze({
|
|
36
|
+
packageName: '@livedesk/fast-osx-arm64',
|
|
37
|
+
rid: 'osx-arm64',
|
|
38
|
+
executableName: 'livedesk-client-fast'
|
|
39
|
+
})
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function exactVersion(value) {
|
|
43
|
+
const version = String(value || '').trim();
|
|
44
|
+
return /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(version)
|
|
45
|
+
? version
|
|
46
|
+
: '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function boundedText(previous, chunk) {
|
|
50
|
+
const next = `${previous}${String(chunk || '')}`;
|
|
51
|
+
return next.length <= MAX_OUTPUT_CHARS ? next : next.slice(next.length - MAX_OUTPUT_CHARS);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function delay(milliseconds) {
|
|
55
|
+
return new Promise(resolveDelay => setTimeout(resolveDelay, milliseconds));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function packageDirectory(installRoot, packageName) {
|
|
59
|
+
return join(installRoot, 'node_modules', ...String(packageName).split('/'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeSegment(value) {
|
|
63
|
+
return String(value || '').replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 160) || 'unknown';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readJson(filePath) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(readFileSync(filePath, 'utf8'));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isPidAlive(value) {
|
|
75
|
+
const pid = Number(value);
|
|
76
|
+
if (!Number.isInteger(pid) || pid <= 1) return false;
|
|
77
|
+
try {
|
|
78
|
+
process.kill(pid, 0);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return error?.code === 'EPERM';
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reclaimStaleLock(lockPath) {
|
|
86
|
+
try {
|
|
87
|
+
const owner = readJson(join(lockPath, 'owner.json'));
|
|
88
|
+
const ageMs = Math.max(0, Date.now() - Number(statSync(lockPath).mtimeMs || 0));
|
|
89
|
+
const ownerAlive = isPidAlive(owner?.pid);
|
|
90
|
+
if ((owner?.pid && !ownerAlive && ageMs >= 1_000) || ageMs >= LOCK_STALE_MS) {
|
|
91
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// A concurrent owner may be creating or removing the lock.
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function resolveFastPlatformSpec({
|
|
101
|
+
platform = process.platform,
|
|
102
|
+
arch = process.arch,
|
|
103
|
+
manifest = null
|
|
104
|
+
} = {}) {
|
|
105
|
+
const base = PLATFORM_SPECS[`${platform}:${arch}`];
|
|
106
|
+
if (!base) return null;
|
|
107
|
+
const version = exactVersion(manifest?.optionalDependencies?.[base.packageName]);
|
|
108
|
+
if (!version) return null;
|
|
109
|
+
return Object.freeze({ ...base, version });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function inspectFastRuntimePackage(packageRoot, spec) {
|
|
113
|
+
if (!packageRoot || !spec) return null;
|
|
114
|
+
const manifest = readJson(join(packageRoot, 'package.json'));
|
|
115
|
+
if (manifest?.name !== spec.packageName || manifest?.version !== spec.version) return null;
|
|
116
|
+
if (Array.isArray(manifest.os) && !manifest.os.includes(process.platform)) return null;
|
|
117
|
+
if (Array.isArray(manifest.cpu) && !manifest.cpu.includes(process.arch)) return null;
|
|
118
|
+
const fastRoot = join(packageRoot, 'fast');
|
|
119
|
+
const executable = join(fastRoot, spec.executableName);
|
|
120
|
+
const dll = join(fastRoot, 'livedesk-client-fast.dll');
|
|
121
|
+
if (!existsSync(executable) && !existsSync(dll)) return null;
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
rid: spec.rid,
|
|
124
|
+
packageName: spec.packageName,
|
|
125
|
+
packageVersion: spec.version,
|
|
126
|
+
packageRoot,
|
|
127
|
+
executable,
|
|
128
|
+
dll
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function repairedFastInstallRoot(stateDir, spec) {
|
|
133
|
+
return join(
|
|
134
|
+
resolve(stateDir),
|
|
135
|
+
'runtime-packages',
|
|
136
|
+
`${safeSegment(spec.packageName)}-${safeSegment(spec.version)}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function inspectRepairedFastRuntime(stateDir, spec) {
|
|
141
|
+
const installRoot = repairedFastInstallRoot(stateDir, spec);
|
|
142
|
+
return inspectFastRuntimePackage(packageDirectory(installRoot, spec.packageName), spec);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveNpmInvocation({ nodeExecutable, npmExecPath, npmExecutable }) {
|
|
146
|
+
const exactNode = resolve(String(nodeExecutable || process.execPath));
|
|
147
|
+
const cli = String(npmExecPath || '').trim();
|
|
148
|
+
const cliCandidates = cli
|
|
149
|
+
? (/npx-cli\.js$/i.test(cli) ? [join(dirname(cli), 'npm-cli.js')] : [cli])
|
|
150
|
+
: [];
|
|
151
|
+
const npmCli = cliCandidates.find(candidate => existsSync(candidate));
|
|
152
|
+
if (npmCli) {
|
|
153
|
+
return { command: exactNode, argsPrefix: [resolve(npmCli)] };
|
|
154
|
+
}
|
|
155
|
+
const executable = String(npmExecutable || '').trim();
|
|
156
|
+
if (executable) return { command: executable, argsPrefix: [] };
|
|
157
|
+
const nodeDir = dirname(exactNode);
|
|
158
|
+
const candidates = process.platform === 'win32'
|
|
159
|
+
? [join(nodeDir, 'npm.cmd'), join(nodeDir, 'npm.exe')]
|
|
160
|
+
: [join(nodeDir, 'npm')];
|
|
161
|
+
const fallback = candidates.find(existsSync);
|
|
162
|
+
return fallback ? { command: fallback, argsPrefix: [] } : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function runInstall({ invocation, installRoot, spec, timeoutMs, env }) {
|
|
166
|
+
return new Promise((resolveInstall, rejectInstall) => {
|
|
167
|
+
const args = [
|
|
168
|
+
...invocation.argsPrefix,
|
|
169
|
+
'install',
|
|
170
|
+
'--ignore-scripts',
|
|
171
|
+
'--no-audit',
|
|
172
|
+
'--no-fund',
|
|
173
|
+
'--package-lock=false',
|
|
174
|
+
'--save=false',
|
|
175
|
+
'--omit=dev',
|
|
176
|
+
'--prefix', installRoot,
|
|
177
|
+
`${spec.packageName}@${spec.version}`
|
|
178
|
+
];
|
|
179
|
+
const child = spawn(invocation.command, args, {
|
|
180
|
+
env: {
|
|
181
|
+
...env,
|
|
182
|
+
npm_config_audit: 'false',
|
|
183
|
+
npm_config_fund: 'false',
|
|
184
|
+
npm_config_ignore_scripts: 'true',
|
|
185
|
+
npm_config_package_lock: 'false'
|
|
186
|
+
},
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
189
|
+
});
|
|
190
|
+
let stdout = '';
|
|
191
|
+
let stderr = '';
|
|
192
|
+
let settled = false;
|
|
193
|
+
let timer = null;
|
|
194
|
+
const finish = (error, result = null) => {
|
|
195
|
+
if (settled) return;
|
|
196
|
+
settled = true;
|
|
197
|
+
if (timer) clearTimeout(timer);
|
|
198
|
+
if (error) rejectInstall(error);
|
|
199
|
+
else resolveInstall(result);
|
|
200
|
+
};
|
|
201
|
+
child.stdout?.on('data', chunk => { stdout = boundedText(stdout, chunk); });
|
|
202
|
+
child.stderr?.on('data', chunk => { stderr = boundedText(stderr, chunk); });
|
|
203
|
+
child.once('error', error => finish(error));
|
|
204
|
+
child.once('exit', (code, signal) => finish(null, {
|
|
205
|
+
code: Number.isInteger(code) ? code : 1,
|
|
206
|
+
signal: String(signal || ''),
|
|
207
|
+
stdout,
|
|
208
|
+
stderr
|
|
209
|
+
}));
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
try { child.kill('SIGKILL'); } catch { }
|
|
212
|
+
finish(new Error(`remote-fast-repair-timeout:${timeoutMs}`));
|
|
213
|
+
}, timeoutMs);
|
|
214
|
+
timer.unref?.();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function ensureRepairedFastRuntime({
|
|
219
|
+
stateDir,
|
|
220
|
+
spec,
|
|
221
|
+
nodeExecutable = process.execPath,
|
|
222
|
+
npmExecPath = process.env.npm_execpath || process.env.NPM_EXECPATH,
|
|
223
|
+
npmExecutable = process.env.LIVEDESK_NPM_EXECUTABLE,
|
|
224
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
225
|
+
env = process.env,
|
|
226
|
+
installRunner = runInstall
|
|
227
|
+
} = {}) {
|
|
228
|
+
if (!stateDir || !spec) throw new Error('remote-fast-repair-plan-required');
|
|
229
|
+
const alreadyRepaired = inspectRepairedFastRuntime(stateDir, spec);
|
|
230
|
+
if (alreadyRepaired) return alreadyRepaired;
|
|
231
|
+
|
|
232
|
+
const installRoot = repairedFastInstallRoot(stateDir, spec);
|
|
233
|
+
mkdirSync(dirname(installRoot), { recursive: true });
|
|
234
|
+
const lockPath = `${installRoot}.lock`;
|
|
235
|
+
const deadline = Date.now() + Math.max(10_000, Math.min(300_000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS));
|
|
236
|
+
let ownsLock = false;
|
|
237
|
+
while (!ownsLock && Date.now() < deadline) {
|
|
238
|
+
try {
|
|
239
|
+
mkdirSync(lockPath, { recursive: false });
|
|
240
|
+
writeFileSync(join(lockPath, 'owner.json'), JSON.stringify({
|
|
241
|
+
pid: process.pid,
|
|
242
|
+
createdAt: new Date().toISOString(),
|
|
243
|
+
packageName: spec.packageName,
|
|
244
|
+
version: spec.version
|
|
245
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
246
|
+
ownsLock = true;
|
|
247
|
+
} catch {
|
|
248
|
+
const concurrent = inspectRepairedFastRuntime(stateDir, spec);
|
|
249
|
+
if (concurrent) return concurrent;
|
|
250
|
+
if (!reclaimStaleLock(lockPath)) await delay(LOCK_POLL_MS);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (!ownsLock) throw new Error('remote-fast-repair-lock-timeout');
|
|
254
|
+
|
|
255
|
+
const temporaryRoot = `${installRoot}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`;
|
|
256
|
+
try {
|
|
257
|
+
const afterLock = inspectRepairedFastRuntime(stateDir, spec);
|
|
258
|
+
if (afterLock) return afterLock;
|
|
259
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
260
|
+
mkdirSync(temporaryRoot, { recursive: true });
|
|
261
|
+
writeFileSync(join(temporaryRoot, 'package.json'), JSON.stringify({
|
|
262
|
+
private: true,
|
|
263
|
+
name: 'livedesk-fast-runtime-repair',
|
|
264
|
+
version: '0.0.0'
|
|
265
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
266
|
+
|
|
267
|
+
const invocation = resolveNpmInvocation({ nodeExecutable, npmExecPath, npmExecutable });
|
|
268
|
+
if (!invocation && installRunner === runInstall) throw new Error('remote-fast-repair-npm-unavailable');
|
|
269
|
+
const remainingMs = Math.max(1_000, deadline - Date.now());
|
|
270
|
+
const result = await installRunner({
|
|
271
|
+
invocation,
|
|
272
|
+
installRoot: temporaryRoot,
|
|
273
|
+
spec,
|
|
274
|
+
timeoutMs: remainingMs,
|
|
275
|
+
env
|
|
276
|
+
});
|
|
277
|
+
if (result?.code !== 0) {
|
|
278
|
+
const detail = String(result?.stderr || result?.stdout || `exit-${result?.code ?? 'unknown'}`)
|
|
279
|
+
.replace(/\s+/g, ' ')
|
|
280
|
+
.trim()
|
|
281
|
+
.slice(0, 600);
|
|
282
|
+
throw new Error(`remote-fast-repair-install-failed:${detail || 'unknown'}`);
|
|
283
|
+
}
|
|
284
|
+
const temporaryRuntime = inspectFastRuntimePackage(
|
|
285
|
+
packageDirectory(temporaryRoot, spec.packageName),
|
|
286
|
+
spec
|
|
287
|
+
);
|
|
288
|
+
if (!temporaryRuntime) throw new Error('remote-fast-repair-package-invalid');
|
|
289
|
+
|
|
290
|
+
rmSync(installRoot, { recursive: true, force: true });
|
|
291
|
+
mkdirSync(dirname(installRoot), { recursive: true });
|
|
292
|
+
renameSync(temporaryRoot, installRoot);
|
|
293
|
+
const committed = inspectRepairedFastRuntime(stateDir, spec);
|
|
294
|
+
if (!committed) throw new Error('remote-fast-repair-commit-invalid');
|
|
295
|
+
return committed;
|
|
296
|
+
} finally {
|
|
297
|
+
rmSync(temporaryRoot, { recursive: true, force: true });
|
|
298
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -1,190 +1,219 @@
|
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|| !
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (!
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
state.
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
state
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, 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 credentialRoot() {
|
|
21
|
+
const configured = clean(process.env.LIVEDESK_CLIENT_CREDENTIAL_ROOT, 4096);
|
|
22
|
+
return configured ? path.resolve(configured) : path.join(os.homedir(), '.livedesk-client');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function defaultCredentialPath(deviceId) {
|
|
26
|
+
const deviceHash = crypto.createHash('sha256').update(String(deviceId), 'utf8').digest('hex');
|
|
27
|
+
return path.join(credentialRoot(), 'security', 'device-credentials', `${deviceHash}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function legacyCredentialPath() {
|
|
31
|
+
return path.join(credentialRoot(), 'device-credential-v1.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function atomicPrivateJson(filePath, value) {
|
|
35
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
36
|
+
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
37
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
38
|
+
renameSync(temporary, filePath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function loadJson(filePath) {
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
44
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function requirePrivateKey(value) {
|
|
51
|
+
const der = decodeCanonicalBase64Url(value, 100, 512);
|
|
52
|
+
let key;
|
|
53
|
+
try {
|
|
54
|
+
key = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
|
|
55
|
+
} catch {
|
|
56
|
+
throw credentialError('device-private-key-invalid');
|
|
57
|
+
}
|
|
58
|
+
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
|
|
59
|
+
throw credentialError('device-private-key-invalid');
|
|
60
|
+
}
|
|
61
|
+
return key;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseCredential(credential) {
|
|
65
|
+
const text = String(credential || '');
|
|
66
|
+
const parts = text.split('.');
|
|
67
|
+
if (parts.length !== 2 || text.length > 8192) throw credentialError('device-credential-invalid');
|
|
68
|
+
const payloadBytes = decodeCanonicalBase64Url(parts[0], 32, 4096);
|
|
69
|
+
const signature = decodeCanonicalBase64Url(parts[1], 64, 64);
|
|
70
|
+
let payload;
|
|
71
|
+
try {
|
|
72
|
+
payload = JSON.parse(payloadBytes.toString('utf8'));
|
|
73
|
+
} catch {
|
|
74
|
+
throw credentialError('device-credential-invalid');
|
|
75
|
+
}
|
|
76
|
+
if (!payload || Number(payload.version) !== 1) throw credentialError('device-credential-invalid');
|
|
77
|
+
const hubPublicDer = decodeCanonicalBase64Url(payload.hubPublicKey, 80, 160);
|
|
78
|
+
let hubPublicKey;
|
|
79
|
+
try {
|
|
80
|
+
hubPublicKey = crypto.createPublicKey({ key: hubPublicDer, format: 'der', type: 'spki' });
|
|
81
|
+
} catch {
|
|
82
|
+
throw credentialError('device-credential-invalid');
|
|
83
|
+
}
|
|
84
|
+
if (hubPublicKey.asymmetricKeyType !== 'ec'
|
|
85
|
+
|| hubPublicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1'
|
|
86
|
+
|| !crypto.verify('sha256', Buffer.from(parts[0], 'utf8'), { key: hubPublicKey, dsaEncoding: 'ieee-p1363' }, signature)) {
|
|
87
|
+
throw credentialError('device-credential-signature-invalid');
|
|
88
|
+
}
|
|
89
|
+
const normalized = {
|
|
90
|
+
serial: clean(payload.serial, 128),
|
|
91
|
+
accountId: clean(payload.accountId, 128),
|
|
92
|
+
hubId: clean(payload.hubId, 128),
|
|
93
|
+
deviceId: clean(payload.deviceId, 128),
|
|
94
|
+
devicePublicKey: clean(payload.devicePublicKey, 512),
|
|
95
|
+
hubPublicKey: hubPublicDer.toString('base64url'),
|
|
96
|
+
issuedAt: Number(payload.issuedAt),
|
|
97
|
+
expiresAt: Number(payload.expiresAt)
|
|
98
|
+
};
|
|
99
|
+
if (!normalized.serial || !normalized.accountId || !normalized.hubId || !normalized.deviceId
|
|
100
|
+
|| !Number.isSafeInteger(normalized.issuedAt) || !Number.isSafeInteger(normalized.expiresAt)
|
|
101
|
+
|| normalized.expiresAt <= Date.now()) {
|
|
102
|
+
throw credentialError('device-credential-expired');
|
|
103
|
+
}
|
|
104
|
+
return { text, payloadText: parts[0], payload: normalized, hubPublicKey };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createClientDeviceCredentialStore({
|
|
108
|
+
filePath = String(process.env.LIVEDESK_DEVICE_CREDENTIAL_PATH || '').trim(),
|
|
109
|
+
deviceId
|
|
110
|
+
} = {}) {
|
|
111
|
+
const normalizedDeviceId = clean(deviceId, 128);
|
|
112
|
+
if (!normalizedDeviceId) throw credentialError('device-id-required');
|
|
113
|
+
const configuredPath = clean(filePath, 4096);
|
|
114
|
+
const resolvedFilePath = configuredPath
|
|
115
|
+
? path.resolve(configuredPath)
|
|
116
|
+
: defaultCredentialPath(normalizedDeviceId);
|
|
117
|
+
const privateKeyStore = createOsSecretStore({
|
|
118
|
+
service: 'LiveDesk',
|
|
119
|
+
account: `client-device-private-key:${normalizedDeviceId}`,
|
|
120
|
+
dataDir: path.dirname(resolvedFilePath)
|
|
121
|
+
});
|
|
122
|
+
let state = loadJson(resolvedFilePath);
|
|
123
|
+
let migratedLegacy = false;
|
|
124
|
+
if (!state && !configuredPath && !existsSync(resolvedFilePath)) {
|
|
125
|
+
const legacyState = loadJson(legacyCredentialPath());
|
|
126
|
+
if (Number(legacyState?.version) === STORE_VERSION && legacyState?.deviceId === normalizedDeviceId) {
|
|
127
|
+
state = legacyState;
|
|
128
|
+
migratedLegacy = true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
let privateKey;
|
|
132
|
+
let publicKey;
|
|
133
|
+
if (state) {
|
|
134
|
+
if (Number(state.version) !== STORE_VERSION || state.deviceId !== normalizedDeviceId) throw credentialError('device-key-state-invalid');
|
|
135
|
+
const plaintextPrivateKey = clean(state.privateKey, 1024);
|
|
136
|
+
if (plaintextPrivateKey) {
|
|
137
|
+
if (!privateKeyStore.write(plaintextPrivateKey)) throw credentialError('device-private-key-migration-failed');
|
|
138
|
+
state = { ...state, privateKeyRef: OS_SECRET_REFERENCE, updatedAt: new Date().toISOString() };
|
|
139
|
+
delete state.privateKey;
|
|
140
|
+
atomicPrivateJson(resolvedFilePath, state);
|
|
141
|
+
}
|
|
142
|
+
if (state.privateKeyRef !== OS_SECRET_REFERENCE) throw credentialError('device-private-key-reference-invalid');
|
|
143
|
+
const privateKeyText = privateKeyStore.read();
|
|
144
|
+
if (!privateKeyText) throw credentialError('device-private-key-secure-store-unavailable');
|
|
145
|
+
privateKey = requirePrivateKey(privateKeyText);
|
|
146
|
+
publicKey = crypto.createPublicKey(privateKey);
|
|
147
|
+
const publicText = publicKey.export({ format: 'der', type: 'spki' }).toString('base64url');
|
|
148
|
+
if (publicText !== state.publicKey) throw credentialError('device-key-mismatch');
|
|
149
|
+
if (migratedLegacy) {
|
|
150
|
+
atomicPrivateJson(resolvedFilePath, state);
|
|
151
|
+
try { rmSync(legacyCredentialPath(), { force: true }); } catch {}
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
const generated = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
|
155
|
+
privateKey = generated.privateKey;
|
|
156
|
+
publicKey = generated.publicKey;
|
|
157
|
+
const privateKeyText = privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url');
|
|
158
|
+
if (!privateKeyStore.write(privateKeyText)) throw credentialError('device-private-key-secure-store-unavailable');
|
|
159
|
+
state = {
|
|
160
|
+
version: STORE_VERSION,
|
|
161
|
+
deviceId: normalizedDeviceId,
|
|
162
|
+
publicKey: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'),
|
|
163
|
+
privateKeyRef: OS_SECRET_REFERENCE,
|
|
164
|
+
credential: '',
|
|
165
|
+
createdAt: new Date().toISOString(),
|
|
166
|
+
updatedAt: new Date().toISOString()
|
|
167
|
+
};
|
|
168
|
+
atomicPrivateJson(resolvedFilePath, state);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function saveCredential(credential) {
|
|
172
|
+
const parsed = parseCredential(credential);
|
|
173
|
+
if (parsed.payload.deviceId !== normalizedDeviceId || parsed.payload.devicePublicKey !== state.publicKey) {
|
|
174
|
+
throw credentialError('device-credential-binding-invalid');
|
|
175
|
+
}
|
|
176
|
+
state.credential = parsed.text;
|
|
177
|
+
state.accountId = parsed.payload.accountId;
|
|
178
|
+
state.hubId = parsed.payload.hubId;
|
|
179
|
+
state.updatedAt = new Date().toISOString();
|
|
180
|
+
atomicPrivateJson(resolvedFilePath, state);
|
|
181
|
+
return parsed;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function readCredential() {
|
|
185
|
+
if (!state.credential) return null;
|
|
186
|
+
try {
|
|
187
|
+
const parsed = parseCredential(state.credential);
|
|
188
|
+
if (parsed.payload.deviceId !== normalizedDeviceId || parsed.payload.devicePublicKey !== state.publicKey) return null;
|
|
189
|
+
return parsed;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function clearCredential() {
|
|
196
|
+
state.credential = '';
|
|
197
|
+
state.accountId = '';
|
|
198
|
+
state.hubId = '';
|
|
199
|
+
state.updatedAt = new Date().toISOString();
|
|
200
|
+
atomicPrivateJson(resolvedFilePath, state);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return Object.freeze({
|
|
204
|
+
filePath: resolvedFilePath,
|
|
205
|
+
deviceId: normalizedDeviceId,
|
|
206
|
+
publicKey: state.publicKey,
|
|
207
|
+
privateKey,
|
|
208
|
+
sign(message) {
|
|
209
|
+
return crypto.sign('sha256', Buffer.from(String(message || ''), 'utf8'), {
|
|
210
|
+
key: privateKey,
|
|
211
|
+
dsaEncoding: 'ieee-p1363'
|
|
212
|
+
}).toString('base64url');
|
|
213
|
+
},
|
|
214
|
+
readCredential,
|
|
215
|
+
saveCredential,
|
|
216
|
+
clearCredential,
|
|
217
|
+
parseCredential
|
|
218
|
+
});
|
|
219
|
+
}
|