@livedesk/client 0.1.234 → 0.1.236

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,224 +0,0 @@
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
- }