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.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- 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
|
+
});
|