spck 0.3.1

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.
Files changed (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,113 @@
1
+ /**
2
+ * ProxySocketWrapper - Emulates AuthenticatedSocket for proxy mode
3
+ * Routes socket.emit() calls through ProxyClient.sendToClient()
4
+ */
5
+
6
+ /**
7
+ * Lightweight socket wrapper for proxy mode
8
+ * Provides the minimal interface needed by services (duck typing)
9
+ */
10
+ export class ProxySocketWrapper {
11
+ public data: { uid: string; deviceId: string };
12
+ public broadcast: { emit: (event: string, data?: any) => boolean };
13
+ private eventListeners: Map<string, Set<(...args: any[]) => void>> = new Map();
14
+
15
+ constructor(
16
+ private connectionId: string,
17
+ userId: string,
18
+ private sendFn: (connectionId: string, event: string, data: any) => void,
19
+ deviceId: string
20
+ ) {
21
+ // Set up data property to match AuthenticatedSocket
22
+ // uid = CLI user ID (from Firebase), deviceId = mobile device ID
23
+ this.data = { uid: userId, deviceId };
24
+
25
+ // Set up broadcast - in proxy mode, just send to the single connected client
26
+ this.broadcast = {
27
+ emit: (event: string, data?: any) => {
28
+ return this.emit(event, data);
29
+ }
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Emit event to client via proxy
35
+ * This is the main method used by services to send data back to clients
36
+ */
37
+ emit(event: string, data?: any): boolean {
38
+ // Send through proxy client
39
+ this.sendFn(this.connectionId, event, data || {});
40
+
41
+ return true; // Return true to match socket.io API
42
+ }
43
+
44
+ /**
45
+ * Get connection ID
46
+ */
47
+ get id(): string {
48
+ return this.connectionId;
49
+ }
50
+
51
+ /**
52
+ * Event handling methods
53
+ * These store listeners that are triggered when messages arrive from the client
54
+ */
55
+
56
+ on(event: string, listener: (...args: any[]) => void): this {
57
+ if (!this.eventListeners.has(event)) {
58
+ this.eventListeners.set(event, new Set());
59
+ }
60
+ this.eventListeners.get(event)!.add(listener);
61
+ return this;
62
+ }
63
+
64
+ once(event: string, listener: (...args: any[]) => void): this {
65
+ const onceWrapper = (...args: any[]) => {
66
+ this.off(event, onceWrapper);
67
+ listener(...args);
68
+ };
69
+ return this.on(event, onceWrapper);
70
+ }
71
+
72
+ off(event: string, listener?: (...args: any[]) => void): this {
73
+ if (!listener) {
74
+ // Remove all listeners for this event
75
+ this.eventListeners.delete(event);
76
+ return this;
77
+ }
78
+
79
+ const listeners = this.eventListeners.get(event);
80
+ if (listeners) {
81
+ listeners.delete(listener);
82
+ if (listeners.size === 0) {
83
+ this.eventListeners.delete(event);
84
+ }
85
+ }
86
+ return this;
87
+ }
88
+
89
+ removeAllListeners(event?: string): this {
90
+ if (event) {
91
+ this.eventListeners.delete(event);
92
+ } else {
93
+ this.eventListeners.clear();
94
+ }
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Trigger event listeners (called by ProxyClient when messages arrive)
100
+ */
101
+ triggerEvent(event: string, ...args: any[]): void {
102
+ const listeners = this.eventListeners.get(event);
103
+ if (listeners) {
104
+ listeners.forEach(listener => {
105
+ try {
106
+ listener(...args);
107
+ } catch (error) {
108
+ console.error(`Error in event listener for '${event}':`, error);
109
+ }
110
+ });
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,575 @@
1
+ import { describe, it, expect, beforeEach, vi, type Mock, type Mocked } from 'vitest';
2
+ /**
3
+ * Tests for ProxyClient security - Handshake protocol and HMAC verification
4
+ *
5
+ * These tests verify the security fixes for:
6
+ * 1. Handshake bypass prevention (protocol_selected requires authentication)
7
+ * 2. User authentication enforcement when userAuthenticationEnabled is true
8
+ * 3. HMAC verification for all RPC requests
9
+ */
10
+
11
+ import * as crypto from 'crypto';
12
+ import { ErrorCode } from '../../types.js';
13
+
14
+ // Mock dependencies
15
+ vi.mock('socket.io-client', () => ({
16
+ io: vi.fn(() => mockSocket),
17
+ }));
18
+
19
+ vi.mock('qrcode-terminal', () => ({
20
+ default: { generate: vi.fn() },
21
+ generate: vi.fn(),
22
+ }));
23
+
24
+ vi.mock('../../connection/auth.js', () => ({
25
+ verifyFirebaseToken: vi.fn(),
26
+ }));
27
+
28
+ vi.mock('../../config/credentials.js', () => ({
29
+ saveConnectionSettings: vi.fn(),
30
+ loadConnectionSettings: vi.fn(),
31
+ isServerTokenExpired: vi.fn(),
32
+ loadGlobalConfig: vi.fn(() => ({ knownDeviceIds: [] })),
33
+ saveGlobalConfig: vi.fn(),
34
+ }));
35
+
36
+ vi.mock('../../rpc/router.js', () => ({
37
+ RPCRouter: {
38
+ initialize: vi.fn(),
39
+ route: vi.fn(),
40
+ cleanup: vi.fn(),
41
+ },
42
+ }));
43
+
44
+ vi.mock('../handshake-validation.js', () => ({
45
+ validateHandshakeTimestamp: vi.fn(() => ({ valid: true })),
46
+ }));
47
+
48
+ vi.mock('../../connection/hmac.js', () => ({
49
+ requireValidHMAC: vi.fn(),
50
+ validateHMAC: vi.fn(() => true),
51
+ }));
52
+
53
+ import { ProxyClient } from '../ProxyClient.js';
54
+ import { requireValidHMAC } from '../../connection/hmac.js';
55
+ import { RPCRouter } from '../../rpc/router.js';
56
+ import { validateHandshakeTimestamp } from '../handshake-validation.js';
57
+ import { io } from 'socket.io-client';
58
+ import { loadGlobalConfig, saveGlobalConfig } from '../../config/credentials.js';
59
+
60
+ const mockRequireValidHMAC = requireValidHMAC as Mock;
61
+ const mockRPCRouter = RPCRouter as Mocked<typeof RPCRouter>;
62
+ const mockValidateHandshakeTimestamp = validateHandshakeTimestamp as Mock;
63
+ const mockLoadGlobalConfig = loadGlobalConfig as Mock;
64
+ const mockSaveGlobalConfig = saveGlobalConfig as Mock;
65
+
66
+ // Shared mock socket that all tests use
67
+ let mockSocket: any;
68
+ let eventHandlers: Record<string, Function[]>;
69
+
70
+ const TEST_SECRET = 'test-secret-key-12345678901234567890';
71
+ const TEST_CLIENT_ID = 'test-client-id';
72
+
73
+ /**
74
+ * Helper to trigger all handlers for an event
75
+ */
76
+ async function triggerEvent(event: string, data: any): Promise<void> {
77
+ const handlers = eventHandlers[event] || [];
78
+ for (const handler of handlers) {
79
+ await handler(data);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Create HMAC-signed auth message
85
+ */
86
+ function createAuthMessage(secret: string, clientId: string, deviceId: string = 'test-device-123'): any {
87
+ const timestamp = Date.now();
88
+ const nonce = Math.random().toString(36).substring(2);
89
+ const message = { type: 'auth', clientId, timestamp, nonce, deviceId };
90
+ const messageToSign = timestamp + JSON.stringify({ type: 'auth', clientId, nonce, deviceId });
91
+ const hmac = crypto.createHmac('sha256', secret).update(messageToSign).digest('hex');
92
+ return { ...message, hmac };
93
+ }
94
+
95
+ /**
96
+ * Create signed RPC message with HMAC
97
+ */
98
+ function createSignedRPCMessage(secret: string, method: string, params: any, id: number = 1): any {
99
+ const timestamp = Date.now();
100
+ const payload = { jsonrpc: '2.0', method, params, id };
101
+ const messageToSign = timestamp + JSON.stringify(payload);
102
+ const hmac = crypto.createHmac('sha256', secret).update(messageToSign).digest('hex');
103
+ return { ...payload, timestamp, hmac };
104
+ }
105
+
106
+ describe('ProxyClient Security', () => {
107
+ const defaultOptions = {
108
+ config: {
109
+ security: { userAuthenticationEnabled: false },
110
+ terminal: { enabled: true },
111
+ filesystem: { maxFileSize: '100MB', watchIgnorePatterns: [] },
112
+ rootPath: '/test/root',
113
+ },
114
+ firebaseToken: 'mock-firebase-token',
115
+ userId: 'test-user-123',
116
+ tools: {
117
+ node: { available: true, version: '18.0.0', path: '/usr/bin/node' },
118
+ git: { available: true, version: '2.39.0', path: '/usr/bin/git' },
119
+ },
120
+ existingConnectionSettings: {
121
+ serverToken: 'existing-server-token',
122
+ serverTokenExpiry: Date.now() + 86400000,
123
+ clientId: TEST_CLIENT_ID,
124
+ secret: TEST_SECRET,
125
+ userId: 'test-user-123',
126
+ connectedAt: Date.now(),
127
+ },
128
+ proxyServerUrl: 'cli-na-1.spck.io',
129
+ };
130
+
131
+ beforeEach(() => {
132
+ vi.clearAllMocks();
133
+ eventHandlers = {};
134
+
135
+ // Create fresh mock socket for each test
136
+ mockSocket = {
137
+ on: vi.fn((event: string, handler: Function) => {
138
+ if (!eventHandlers[event]) eventHandlers[event] = [];
139
+ eventHandlers[event].push(handler);
140
+ }),
141
+ once: vi.fn((event: string, handler: Function) => {
142
+ if (!eventHandlers[event]) eventHandlers[event] = [];
143
+ eventHandlers[event].push(handler);
144
+ }),
145
+ off: vi.fn(),
146
+ emit: vi.fn(),
147
+ disconnect: vi.fn(),
148
+ id: 'mock-socket-id',
149
+ };
150
+
151
+ // Update the io mock to return our fresh socket
152
+ (io as Mock).mockReturnValue(mockSocket);
153
+
154
+ mockValidateHandshakeTimestamp.mockReturnValue({ valid: true });
155
+ mockRequireValidHMAC.mockImplementation(() => {});
156
+ });
157
+
158
+ /**
159
+ * Setup an authenticated client and return it
160
+ */
161
+ async function setupClient(options = defaultOptions): Promise<ProxyClient> {
162
+ const client = new ProxyClient(options as any);
163
+
164
+ // Start connection (non-blocking)
165
+ client.connect().catch(() => {}); // Ignore connection errors in tests
166
+
167
+ // Wait for handlers to be set up
168
+ await new Promise(resolve => setImmediate(resolve));
169
+
170
+ // Trigger authenticated event
171
+ await triggerEvent('authenticated', {
172
+ clientId: TEST_CLIENT_ID,
173
+ token: 'mock-server-token',
174
+ expiresAt: Date.now() + 86400000,
175
+ userId: 'test-user-123',
176
+ });
177
+
178
+ await new Promise(resolve => setImmediate(resolve));
179
+ return client;
180
+ }
181
+
182
+ describe('Handshake Bypass Prevention', () => {
183
+ it('should reject protocol_selected from unauthenticated connection', async () => {
184
+ await setupClient();
185
+
186
+ // Client connects but doesn't authenticate
187
+ await triggerEvent('client_connecting', { connectionId: 'attacker-1' });
188
+
189
+ // Try to skip auth and send protocol_selected directly
190
+ await triggerEvent('client_message', {
191
+ connectionId: 'attacker-1',
192
+ data: { type: 'protocol_selected', version: 1 },
193
+ });
194
+
195
+ // Should receive error
196
+ expect(mockSocket.emit).toHaveBeenCalledWith(
197
+ 'handshake',
198
+ expect.objectContaining({
199
+ connectionId: 'attacker-1',
200
+ data: expect.objectContaining({
201
+ type: 'error',
202
+ code: 'not_authenticated',
203
+ }),
204
+ })
205
+ );
206
+ });
207
+
208
+ it('should reject RPC from connection without completed handshake', async () => {
209
+ await setupClient();
210
+
211
+ await triggerEvent('client_connecting', { connectionId: 'partial-1' });
212
+
213
+ // Authenticate but don't complete protocol selection
214
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
215
+
216
+ await triggerEvent('client_message', {
217
+ connectionId: 'partial-1',
218
+ data: authMessage,
219
+ });
220
+
221
+ // Try to send RPC before completing handshake
222
+ const rpcMessage = createSignedRPCMessage(TEST_SECRET, 'terminal.list', {});
223
+ await triggerEvent('client_message', {
224
+ connectionId: 'partial-1',
225
+ data: rpcMessage,
226
+ });
227
+
228
+ // RPCRouter should NOT be called
229
+ expect(mockRPCRouter.route).not.toHaveBeenCalled();
230
+ });
231
+
232
+ it('should accept RPC after completed handshake', async () => {
233
+ await setupClient();
234
+
235
+ await triggerEvent('client_connecting', { connectionId: 'valid-1' });
236
+
237
+ // Complete full handshake
238
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
239
+
240
+ await triggerEvent('client_message', {
241
+ connectionId: 'valid-1',
242
+ data: authMessage,
243
+ });
244
+
245
+ await triggerEvent('client_message', {
246
+ connectionId: 'valid-1',
247
+ data: { type: 'protocol_selected', version: 1 },
248
+ });
249
+
250
+ // Now send RPC
251
+ mockRPCRouter.route.mockResolvedValue([]);
252
+ const rpcMessage = createSignedRPCMessage(TEST_SECRET, 'terminal.list', {});
253
+
254
+ await triggerEvent('client_message', {
255
+ connectionId: 'valid-1',
256
+ data: rpcMessage,
257
+ });
258
+
259
+ // RPCRouter should be called
260
+ expect(mockRPCRouter.route).toHaveBeenCalled();
261
+ });
262
+ });
263
+
264
+ describe('User Authentication Enforcement', () => {
265
+ it('should require user verification when enabled', async () => {
266
+ const optionsWithUserAuth = {
267
+ ...defaultOptions,
268
+ config: {
269
+ ...defaultOptions.config,
270
+ security: { userAuthenticationEnabled: true },
271
+ },
272
+ };
273
+
274
+ await setupClient(optionsWithUserAuth);
275
+
276
+ await triggerEvent('client_connecting', { connectionId: 'user-auth-1' });
277
+
278
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
279
+
280
+ await triggerEvent('client_message', {
281
+ connectionId: 'user-auth-1',
282
+ data: authMessage,
283
+ });
284
+
285
+ // Should request user verification
286
+ expect(mockSocket.emit).toHaveBeenCalledWith(
287
+ 'handshake',
288
+ expect.objectContaining({
289
+ connectionId: 'user-auth-1',
290
+ data: expect.objectContaining({
291
+ type: 'request_user_verification',
292
+ }),
293
+ })
294
+ );
295
+ });
296
+
297
+ it('should reject protocol_selected when user verification required but not done', async () => {
298
+ const optionsWithUserAuth = {
299
+ ...defaultOptions,
300
+ config: {
301
+ ...defaultOptions.config,
302
+ security: { userAuthenticationEnabled: true },
303
+ },
304
+ };
305
+
306
+ await setupClient(optionsWithUserAuth);
307
+
308
+ await triggerEvent('client_connecting', { connectionId: 'skip-verify-1' });
309
+
310
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
311
+
312
+ await triggerEvent('client_message', {
313
+ connectionId: 'skip-verify-1',
314
+ data: authMessage,
315
+ });
316
+
317
+ // Try to skip user verification
318
+ await triggerEvent('client_message', {
319
+ connectionId: 'skip-verify-1',
320
+ data: { type: 'protocol_selected', version: 1 },
321
+ });
322
+
323
+ // Should receive error
324
+ expect(mockSocket.emit).toHaveBeenCalledWith(
325
+ 'handshake',
326
+ expect.objectContaining({
327
+ connectionId: 'skip-verify-1',
328
+ data: expect.objectContaining({
329
+ type: 'error',
330
+ code: 'user_verification_required',
331
+ }),
332
+ })
333
+ );
334
+ });
335
+ });
336
+
337
+ describe('HMAC Verification', () => {
338
+ it('should verify HMAC on all RPC requests', async () => {
339
+ await setupClient();
340
+
341
+ await triggerEvent('client_connecting', { connectionId: 'hmac-1' });
342
+
343
+ // Complete handshake
344
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
345
+
346
+ await triggerEvent('client_message', {
347
+ connectionId: 'hmac-1',
348
+ data: authMessage,
349
+ });
350
+
351
+ await triggerEvent('client_message', {
352
+ connectionId: 'hmac-1',
353
+ data: { type: 'protocol_selected', version: 1 },
354
+ });
355
+
356
+ // Send RPC
357
+ mockRPCRouter.route.mockResolvedValue({ result: 'ok' });
358
+ const rpcMessage = createSignedRPCMessage(TEST_SECRET, 'fs.readFile', { path: '/test.txt' });
359
+
360
+ await triggerEvent('client_message', {
361
+ connectionId: 'hmac-1',
362
+ data: rpcMessage,
363
+ });
364
+
365
+ // HMAC should be verified
366
+ expect(mockRequireValidHMAC).toHaveBeenCalledWith(
367
+ expect.objectContaining({ method: 'fs.readFile' }),
368
+ TEST_SECRET
369
+ );
370
+ });
371
+
372
+ it('should reject RPC with invalid HMAC', async () => {
373
+ await setupClient();
374
+
375
+ await triggerEvent('client_connecting', { connectionId: 'bad-hmac-1' });
376
+
377
+ // Complete handshake
378
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
379
+
380
+ await triggerEvent('client_message', {
381
+ connectionId: 'bad-hmac-1',
382
+ data: authMessage,
383
+ });
384
+
385
+ await triggerEvent('client_message', {
386
+ connectionId: 'bad-hmac-1',
387
+ data: { type: 'protocol_selected', version: 1 },
388
+ });
389
+
390
+ // Mock HMAC to fail
391
+ mockRequireValidHMAC.mockImplementation(() => {
392
+ throw { code: ErrorCode.HMAC_VALIDATION_FAILED, message: 'HMAC validation failed' };
393
+ });
394
+
395
+ // Send RPC with bad HMAC
396
+ await triggerEvent('client_message', {
397
+ connectionId: 'bad-hmac-1',
398
+ data: {
399
+ jsonrpc: '2.0',
400
+ method: 'fs.readFile',
401
+ params: { path: '/test.txt' },
402
+ id: 1,
403
+ timestamp: Date.now(),
404
+ hmac: 'invalid-signature',
405
+ },
406
+ });
407
+
408
+ // Should return HMAC error
409
+ expect(mockSocket.emit).toHaveBeenCalledWith(
410
+ 'rpc',
411
+ expect.objectContaining({
412
+ connectionId: 'bad-hmac-1',
413
+ data: expect.objectContaining({
414
+ jsonrpc: '2.0',
415
+ error: expect.objectContaining({
416
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
417
+ }),
418
+ id: 1,
419
+ }),
420
+ })
421
+ );
422
+
423
+ // RPCRouter should NOT be called
424
+ expect(mockRPCRouter.route).not.toHaveBeenCalled();
425
+ });
426
+ });
427
+
428
+ describe('Known Device ID Persistence', () => {
429
+ it('should initialize knownDeviceIds from loadGlobalConfig on construction', () => {
430
+ mockLoadGlobalConfig.mockReturnValue({ knownDeviceIds: ['persisted-device-1', 'persisted-device-2'] });
431
+
432
+ // Construction triggers loadGlobalConfig via the field initializer
433
+ new ProxyClient(defaultOptions as any);
434
+
435
+ expect(mockLoadGlobalConfig).toHaveBeenCalled();
436
+ });
437
+
438
+ it('should treat a device from global config as known (no saveGlobalConfig call)', async () => {
439
+ mockLoadGlobalConfig.mockReturnValue({ knownDeviceIds: ['test-device-123'] });
440
+
441
+ await setupClient();
442
+
443
+ await triggerEvent('client_connecting', { connectionId: 'known-conn-1' });
444
+
445
+ // Use the pre-loaded device ID — no new-device path should fire
446
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'test-device-123');
447
+ await triggerEvent('client_message', { connectionId: 'known-conn-1', data: authMessage });
448
+
449
+ expect(mockSaveGlobalConfig).not.toHaveBeenCalled();
450
+ });
451
+
452
+ it('should save global config when a genuinely new device connects', async () => {
453
+ mockLoadGlobalConfig.mockReturnValue({ knownDeviceIds: [] });
454
+
455
+ await setupClient();
456
+
457
+ await triggerEvent('client_connecting', { connectionId: 'new-conn-1' });
458
+
459
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'brand-new-device');
460
+ await triggerEvent('client_message', { connectionId: 'new-conn-1', data: authMessage });
461
+
462
+ expect(mockSaveGlobalConfig).toHaveBeenCalledWith(
463
+ expect.objectContaining({ knownDeviceIds: expect.arrayContaining(['brand-new-device']) })
464
+ );
465
+ });
466
+
467
+ it('should accumulate multiple new device IDs across connections', async () => {
468
+ mockLoadGlobalConfig.mockReturnValue({ knownDeviceIds: [] });
469
+
470
+ await setupClient();
471
+
472
+ await triggerEvent('client_connecting', { connectionId: 'conn-a' });
473
+ await triggerEvent('client_message', {
474
+ connectionId: 'conn-a',
475
+ data: createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'device-alpha'),
476
+ });
477
+
478
+ await triggerEvent('client_connecting', { connectionId: 'conn-b' });
479
+ await triggerEvent('client_message', {
480
+ connectionId: 'conn-b',
481
+ data: createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'device-beta'),
482
+ });
483
+
484
+ expect(mockSaveGlobalConfig).toHaveBeenCalledTimes(2);
485
+ // Second call should include both devices
486
+ expect(mockSaveGlobalConfig).toHaveBeenLastCalledWith(
487
+ expect.objectContaining({
488
+ knownDeviceIds: expect.arrayContaining(['device-alpha', 'device-beta']),
489
+ })
490
+ );
491
+ });
492
+
493
+ it('should not save global config again when an already-seen device reconnects', async () => {
494
+ mockLoadGlobalConfig.mockReturnValue({ knownDeviceIds: [] });
495
+
496
+ await setupClient();
497
+
498
+ // First connection — new device
499
+ await triggerEvent('client_connecting', { connectionId: 'first-conn' });
500
+ await triggerEvent('client_message', {
501
+ connectionId: 'first-conn',
502
+ data: createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'recurring-device'),
503
+ });
504
+
505
+ mockSaveGlobalConfig.mockClear();
506
+
507
+ // Second connection — same device
508
+ await triggerEvent('client_connecting', { connectionId: 'second-conn' });
509
+ await triggerEvent('client_message', {
510
+ connectionId: 'second-conn',
511
+ data: createAuthMessage(TEST_SECRET, TEST_CLIENT_ID, 'recurring-device'),
512
+ });
513
+
514
+ expect(mockSaveGlobalConfig).not.toHaveBeenCalled();
515
+ });
516
+ });
517
+
518
+ describe('Auth HMAC Validation', () => {
519
+ it('should reject auth with invalid HMAC signature', async () => {
520
+ await setupClient();
521
+
522
+ await triggerEvent('client_connecting', { connectionId: 'bad-hmac-auth-1' });
523
+
524
+ // Create auth message with wrong secret
525
+ const badAuthMessage = createAuthMessage('wrong-secret', TEST_CLIENT_ID);
526
+
527
+ await triggerEvent('client_message', {
528
+ connectionId: 'bad-hmac-auth-1',
529
+ data: badAuthMessage,
530
+ });
531
+
532
+ // Should reject
533
+ expect(mockSocket.emit).toHaveBeenCalledWith(
534
+ 'handshake',
535
+ expect.objectContaining({
536
+ connectionId: 'bad-hmac-auth-1',
537
+ data: expect.objectContaining({
538
+ type: 'auth_result',
539
+ success: false,
540
+ }),
541
+ })
542
+ );
543
+ });
544
+
545
+ it('should reject auth with expired timestamp', async () => {
546
+ mockValidateHandshakeTimestamp.mockReturnValue({
547
+ valid: false,
548
+ error: 'Timestamp too old',
549
+ });
550
+
551
+ await setupClient();
552
+
553
+ await triggerEvent('client_connecting', { connectionId: 'old-ts-1' });
554
+
555
+ const authMessage = createAuthMessage(TEST_SECRET, TEST_CLIENT_ID);
556
+
557
+ await triggerEvent('client_message', {
558
+ connectionId: 'old-ts-1',
559
+ data: authMessage,
560
+ });
561
+
562
+ // Should reject
563
+ expect(mockSocket.emit).toHaveBeenCalledWith(
564
+ 'handshake',
565
+ expect.objectContaining({
566
+ connectionId: 'old-ts-1',
567
+ data: expect.objectContaining({
568
+ type: 'auth_result',
569
+ success: false,
570
+ }),
571
+ })
572
+ );
573
+ });
574
+ });
575
+ });