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