@livedesk/client 0.1.230 → 0.1.232
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 +23 -3
- package/package.json +53 -53
- package/src/security/device-credential-store.js +219 -190
package/bin/livedesk-client.js
CHANGED
|
@@ -4171,14 +4171,21 @@ export async function resolveManagerFromSupabase(supabase, options = {}) {
|
|
|
4171
4171
|
if (options.signal?.aborted) {
|
|
4172
4172
|
throw createHubDiscoveryError('hub-registry-aborted', 'LiveDesk Hub registry lookup was cancelled.');
|
|
4173
4173
|
}
|
|
4174
|
-
const
|
|
4174
|
+
const fetchImpl = explicitAuthenticatedFetch(supabase, options.fetchImpl);
|
|
4175
|
+
const supplied = normalizeRuntimeAuthSession(options.session, { requireRefreshToken: true });
|
|
4176
|
+
const suppliedExpiresAt = Number(supplied.session?.expires_at || 0);
|
|
4177
|
+
const suppliedFresh = supplied.ok && (suppliedExpiresAt <= 0
|
|
4178
|
+
|| suppliedExpiresAt - Math.floor(Date.now() / 1000) > SESSION_REFRESH_SKEW_SECONDS);
|
|
4179
|
+
let session = suppliedFresh ? supplied.session : await refreshSessionIfNeeded(supabase);
|
|
4175
4180
|
if (!session?.access_token) {
|
|
4176
4181
|
throw createHubDiscoveryError('hub-registry-auth-required', 'Sign in again before finding the LiveDesk Hub.');
|
|
4177
4182
|
}
|
|
4183
|
+
if (!fetchImpl && suppliedFresh) {
|
|
4184
|
+
session = await activateSupabaseSession(supabase, session);
|
|
4185
|
+
}
|
|
4178
4186
|
if (options.signal?.aborted) {
|
|
4179
4187
|
throw createHubDiscoveryError('hub-registry-aborted', 'LiveDesk Hub registry lookup was cancelled.');
|
|
4180
4188
|
}
|
|
4181
|
-
const fetchImpl = explicitAuthenticatedFetch(supabase, options.fetchImpl);
|
|
4182
4189
|
let data;
|
|
4183
4190
|
if (fetchImpl) {
|
|
4184
4191
|
const payload = await fetchAuthenticatedSupabaseJson(session, 'livedesk_remote_host_targets', {
|
|
@@ -4297,6 +4304,7 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
|
|
|
4297
4304
|
let lastMessage = '';
|
|
4298
4305
|
let wakeListener = null;
|
|
4299
4306
|
let initialError = options.initialError || null;
|
|
4307
|
+
let discoverySession = options.session || null;
|
|
4300
4308
|
console.log('Waiting for a LiveDesk Hub. LiveDesk is listening for Hub-online events with adaptive registry retries as a fallback.');
|
|
4301
4309
|
try {
|
|
4302
4310
|
while (true) {
|
|
@@ -4306,7 +4314,16 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
|
|
|
4306
4314
|
if (!wakeListener) {
|
|
4307
4315
|
try {
|
|
4308
4316
|
wakeListener = await createWakeListener({
|
|
4309
|
-
getAccessToken: async () =>
|
|
4317
|
+
getAccessToken: async () => {
|
|
4318
|
+
const normalized = normalizeRuntimeAuthSession(discoverySession, { requireRefreshToken: true });
|
|
4319
|
+
const expiresAt = Number(normalized.session?.expires_at || 0);
|
|
4320
|
+
if (normalized.ok && (expiresAt <= 0
|
|
4321
|
+
|| expiresAt - Math.floor(Date.now() / 1000) > SESSION_REFRESH_SKEW_SECONDS)) {
|
|
4322
|
+
return normalized.session.access_token;
|
|
4323
|
+
}
|
|
4324
|
+
discoverySession = await refreshSessionIfNeeded(supabase);
|
|
4325
|
+
return discoverySession?.access_token || '';
|
|
4326
|
+
}
|
|
4310
4327
|
});
|
|
4311
4328
|
} catch (error) {
|
|
4312
4329
|
if (attempts === 0) {
|
|
@@ -4325,6 +4342,7 @@ export async function waitForManagerFromSupabase(supabase, options = {}) {
|
|
|
4325
4342
|
signal => resolveManagerFromSupabase(supabase, {
|
|
4326
4343
|
allowRelayFallback: options.allowRelayFallback === true,
|
|
4327
4344
|
probeEndpoint: options.probeEndpoint,
|
|
4345
|
+
session: discoverySession,
|
|
4328
4346
|
signal
|
|
4329
4347
|
}),
|
|
4330
4348
|
{
|
|
@@ -4495,6 +4513,7 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
|
|
|
4495
4513
|
}
|
|
4496
4514
|
return await resolveManagerFromSupabase(supabase, {
|
|
4497
4515
|
allowRelayFallback,
|
|
4516
|
+
session,
|
|
4498
4517
|
signal
|
|
4499
4518
|
});
|
|
4500
4519
|
}
|
|
@@ -4527,6 +4546,7 @@ async function prepareLoginConnection(parsed, existingConnectionPage = null, get
|
|
|
4527
4546
|
if (!resolved) {
|
|
4528
4547
|
resolved = await waitForManagerFromSupabase(supabase, {
|
|
4529
4548
|
allowRelayFallback,
|
|
4549
|
+
session,
|
|
4530
4550
|
initialError: initialDiscoveryError,
|
|
4531
4551
|
shouldStop: () => Boolean(roleRestartRequest?.role)
|
|
4532
4552
|
});
|
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.232",
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|