@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.
@@ -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 session = await refreshSessionIfNeeded(supabase);
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 () => (await refreshSessionIfNeeded(supabase))?.access_token || ''
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.230",
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.430",
46
- "@livedesk/fast-osx-arm64": "0.1.430",
47
- "@livedesk/fast-osx-x64": "0.1.430",
48
- "@livedesk/fast-win-x64": "0.1.430"
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 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
- }
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
+ }