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,509 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ /**
3
+ * Tests for HMAC message signing validation
4
+ */
5
+
6
+ import * as crypto from 'crypto';
7
+ import { validateHMAC, requireValidHMAC, clearNonces, getNonceStats } from '../hmac.js';
8
+ import { JSONRPCRequest, ErrorCode } from '../../types.js';
9
+
10
+ describe('HMAC Validation', () => {
11
+ const signingKey = 'test-signing-key-12345';
12
+
13
+ // Clear nonces before each test
14
+ beforeEach(() => {
15
+ clearNonces();
16
+ });
17
+
18
+ function createSignedMessage(
19
+ method: string,
20
+ params: any,
21
+ timestamp?: number,
22
+ customHmac?: string,
23
+ nonce?: string,
24
+ deviceId?: string
25
+ ): JSONRPCRequest {
26
+ const ts = timestamp || Date.now();
27
+ const nonceValue = nonce || crypto.randomBytes(16).toString('hex');
28
+
29
+ const payload: any = {
30
+ jsonrpc: '2.0' as const,
31
+ method,
32
+ params,
33
+ id: 1,
34
+ nonce: nonceValue,
35
+ };
36
+
37
+ // Include deviceId if provided (must be in payload for HMAC calculation)
38
+ if (deviceId) {
39
+ payload.deviceId = deviceId;
40
+ }
41
+
42
+ const messageToSign = ts + JSON.stringify(payload);
43
+ const hmac =
44
+ customHmac ||
45
+ crypto.createHmac('sha256', signingKey).update(messageToSign).digest('hex');
46
+
47
+ return {
48
+ ...payload,
49
+ timestamp: ts,
50
+ hmac,
51
+ nonce: nonceValue,
52
+ };
53
+ }
54
+
55
+ describe('validateHMAC', () => {
56
+ it('should validate correctly signed message', () => {
57
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
58
+
59
+ const isValid = validateHMAC(message, signingKey);
60
+
61
+ expect(isValid).toBe(true);
62
+ });
63
+
64
+ it('should reject message with invalid HMAC', () => {
65
+ const message = createSignedMessage(
66
+ 'fs.readFile',
67
+ { path: '/test.txt' },
68
+ Date.now(),
69
+ 'invalid-hmac-signature'
70
+ );
71
+
72
+ const isValid = validateHMAC(message, signingKey);
73
+
74
+ expect(isValid).toBe(false);
75
+ });
76
+
77
+ it('should reject message with missing HMAC', () => {
78
+ const message: any = {
79
+ jsonrpc: '2.0',
80
+ method: 'fs.readFile',
81
+ params: { path: '/test.txt' },
82
+ id: 1,
83
+ timestamp: Date.now(),
84
+ nonce: crypto.randomBytes(16).toString('hex'),
85
+ // hmac intentionally missing
86
+ };
87
+
88
+ const isValid = validateHMAC(message, signingKey);
89
+
90
+ expect(isValid).toBe(false);
91
+ });
92
+
93
+ it('should reject message with missing timestamp', () => {
94
+ const message: any = {
95
+ jsonrpc: '2.0',
96
+ method: 'fs.readFile',
97
+ params: { path: '/test.txt' },
98
+ id: 1,
99
+ hmac: 'some-hmac',
100
+ nonce: crypto.randomBytes(16).toString('hex'),
101
+ // timestamp intentionally missing
102
+ };
103
+
104
+ const isValid = validateHMAC(message, signingKey);
105
+
106
+ expect(isValid).toBe(false);
107
+ });
108
+
109
+ it('should reject message signed with wrong key', () => {
110
+ const wrongKey = 'different-signing-key';
111
+ const nonce = crypto.randomBytes(16).toString('hex');
112
+ const payload = {
113
+ jsonrpc: '2.0' as const,
114
+ method: 'fs.readFile',
115
+ params: { path: '/test.txt' },
116
+ id: 1,
117
+ nonce,
118
+ };
119
+ const timestamp = Date.now();
120
+ const messageToSign = timestamp + JSON.stringify(payload);
121
+ const hmac = crypto
122
+ .createHmac('sha256', wrongKey)
123
+ .update(messageToSign)
124
+ .digest('hex');
125
+
126
+ const message: JSONRPCRequest = {
127
+ ...payload,
128
+ timestamp,
129
+ hmac,
130
+ nonce,
131
+ };
132
+
133
+ const isValid = validateHMAC(message, signingKey);
134
+
135
+ expect(isValid).toBe(false);
136
+ });
137
+
138
+ it('should reject message with tampered params', () => {
139
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
140
+
141
+ // Tamper with params after signing
142
+ message.params = { path: '/tampered.txt' };
143
+
144
+ const isValid = validateHMAC(message, signingKey);
145
+
146
+ expect(isValid).toBe(false);
147
+ });
148
+
149
+ it('should reject message with tampered method', () => {
150
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
151
+
152
+ // Tamper with method after signing
153
+ message.method = 'fs.deleteFile';
154
+
155
+ const isValid = validateHMAC(message, signingKey);
156
+
157
+ expect(isValid).toBe(false);
158
+ });
159
+
160
+ it('should validate message with deviceId', () => {
161
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, undefined, 'device-123');
162
+
163
+ const isValid = validateHMAC(message, signingKey);
164
+
165
+ expect(isValid).toBe(true);
166
+ });
167
+
168
+ it('should reject message when deviceId is tampered', () => {
169
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, undefined, 'device-123') as any;
170
+
171
+ // Tamper with deviceId after signing
172
+ message.deviceId = 'device-456';
173
+
174
+ const isValid = validateHMAC(message, signingKey);
175
+
176
+ expect(isValid).toBe(false);
177
+ });
178
+
179
+ it('should use constant-time comparison for HMAC', () => {
180
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
181
+
182
+ // Measure time for correct HMAC
183
+ const startCorrect = process.hrtime.bigint();
184
+ validateHMAC(message, signingKey);
185
+ const correctTime = process.hrtime.bigint() - startCorrect;
186
+
187
+ // Measure time for incorrect HMAC (same length)
188
+ message.hmac = 'a'.repeat(message.hmac!.length);
189
+ const startIncorrect = process.hrtime.bigint();
190
+ validateHMAC(message, signingKey);
191
+ const incorrectTime = process.hrtime.bigint() - startIncorrect;
192
+
193
+ // Time difference should be minimal (constant-time comparison)
194
+ // Allow up to 10x difference for timing variance
195
+ const timeDiff = Number(incorrectTime - correctTime);
196
+ const avgTime = Number((correctTime + incorrectTime) / BigInt(2));
197
+ const relativeDiff = Math.abs(timeDiff) / avgTime;
198
+
199
+ expect(relativeDiff).toBeLessThan(10);
200
+ });
201
+ });
202
+
203
+ describe('requireValidHMAC', () => {
204
+ it('should pass for valid HMAC', () => {
205
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
206
+
207
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
208
+ });
209
+
210
+ it('should throw error for invalid HMAC', () => {
211
+ const message = createSignedMessage(
212
+ 'fs.readFile',
213
+ { path: '/test.txt' },
214
+ Date.now(),
215
+ 'invalid-hmac'
216
+ );
217
+
218
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(
219
+ expect.objectContaining({
220
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
221
+ message: expect.stringContaining('HMAC validation failed'),
222
+ })
223
+ );
224
+ });
225
+
226
+ it('should reject message with old timestamp', () => {
227
+ const threeMinutesAgo = Date.now() - 3 * 60 * 1000;
228
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, threeMinutesAgo);
229
+
230
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(
231
+ expect.objectContaining({
232
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
233
+ message: expect.stringContaining('timestamp too old'),
234
+ })
235
+ );
236
+ });
237
+
238
+ it('should accept message within 2 minute window', () => {
239
+ const oneMinuteAgo = Date.now() - 1 * 60 * 1000;
240
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, oneMinuteAgo);
241
+
242
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
243
+ });
244
+
245
+ it('should allow 1 minute clock skew for future timestamps', () => {
246
+ const thirtySecondsInFuture = Date.now() + 30 * 1000;
247
+ const message = createSignedMessage(
248
+ 'fs.readFile',
249
+ { path: '/test.txt' },
250
+ thirtySecondsInFuture
251
+ );
252
+
253
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
254
+ });
255
+
256
+ it('should reject timestamp more than 1 minute in future', () => {
257
+ const twoMinutesInFuture = Date.now() + 2 * 60 * 1000;
258
+ const message = createSignedMessage(
259
+ 'fs.readFile',
260
+ { path: '/test.txt' },
261
+ twoMinutesInFuture
262
+ );
263
+
264
+ expect(() => requireValidHMAC(message, signingKey)).toThrow(
265
+ expect.objectContaining({
266
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
267
+ message: expect.stringContaining('timestamp too old or invalid'),
268
+ })
269
+ );
270
+ });
271
+
272
+ it('should include timestamp details in error', () => {
273
+ const threeMinutesAgo = Date.now() - 3 * 60 * 1000;
274
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, threeMinutesAgo);
275
+
276
+ try {
277
+ requireValidHMAC(message, signingKey);
278
+ expect.unreachable('Should have thrown error');
279
+ } catch (error: any) {
280
+ expect(error.data).toMatchObject({
281
+ timestamp: threeMinutesAgo,
282
+ serverTime: expect.any(Number),
283
+ });
284
+ }
285
+ });
286
+ });
287
+
288
+ describe('Edge Cases', () => {
289
+ it('should handle empty params', () => {
290
+ const message = createSignedMessage('terminal.create', {});
291
+
292
+ const isValid = validateHMAC(message, signingKey);
293
+
294
+ expect(isValid).toBe(true);
295
+ });
296
+
297
+ it('should handle params with special characters', () => {
298
+ const message = createSignedMessage('fs.writeFile', {
299
+ path: '/test.txt',
300
+ content: 'Special chars: 你好 émojis 🚀',
301
+ });
302
+
303
+ const isValid = validateHMAC(message, signingKey);
304
+
305
+ expect(isValid).toBe(true);
306
+ });
307
+
308
+ it('should handle params with nested objects', () => {
309
+ const message = createSignedMessage('git.commit', {
310
+ dir: '/project',
311
+ message: 'Test commit',
312
+ author: {
313
+ name: 'Test User',
314
+ email: 'test@example.com',
315
+ timestamp: 1234567890,
316
+ },
317
+ });
318
+
319
+ const isValid = validateHMAC(message, signingKey);
320
+
321
+ expect(isValid).toBe(true);
322
+ });
323
+
324
+ it('should handle params with arrays', () => {
325
+ const message = createSignedMessage('git.add', {
326
+ dir: '/project',
327
+ filepaths: ['file1.txt', 'file2.txt', 'file3.txt'],
328
+ });
329
+
330
+ const isValid = validateHMAC(message, signingKey);
331
+
332
+ expect(isValid).toBe(true);
333
+ });
334
+
335
+ it('should handle very long signing keys', () => {
336
+ const longKey = 'x'.repeat(1000);
337
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' });
338
+
339
+ // Re-sign with long key
340
+ const payload = {
341
+ jsonrpc: message.jsonrpc,
342
+ method: message.method,
343
+ params: message.params,
344
+ id: message.id,
345
+ nonce: message.nonce,
346
+ };
347
+ const messageToSign = message.timestamp + JSON.stringify(payload);
348
+ message.hmac = crypto
349
+ .createHmac('sha256', longKey)
350
+ .update(messageToSign)
351
+ .digest('hex');
352
+
353
+ const isValid = validateHMAC(message, longKey);
354
+
355
+ expect(isValid).toBe(true);
356
+ });
357
+ });
358
+
359
+ describe('Replay Attack Prevention', () => {
360
+ it('should accept message with nonce on first use', () => {
361
+ const nonce = crypto.randomBytes(16).toString('hex');
362
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
363
+
364
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
365
+ });
366
+
367
+ it('should reject duplicate nonce (replay attack)', () => {
368
+ const nonce = crypto.randomBytes(16).toString('hex');
369
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
370
+
371
+ // First use should succeed
372
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
373
+
374
+ // Second use should fail (replay attack)
375
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
376
+ expect(() => requireValidHMAC(replayMessage, signingKey)).toThrow(
377
+ expect.objectContaining({
378
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
379
+ message: expect.stringContaining('Duplicate nonce'),
380
+ })
381
+ );
382
+ });
383
+
384
+ it('should include nonce in error data when rejecting replay', () => {
385
+ const nonce = crypto.randomBytes(16).toString('hex');
386
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
387
+
388
+ // First use
389
+ requireValidHMAC(message, signingKey);
390
+
391
+ // Second use (replay)
392
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
393
+ try {
394
+ requireValidHMAC(replayMessage, signingKey);
395
+ expect.unreachable('Should have thrown error');
396
+ } catch (error: any) {
397
+ expect(error.data).toMatchObject({
398
+ nonce: nonce,
399
+ });
400
+ }
401
+ });
402
+
403
+ it('should accept different nonces', () => {
404
+ const nonce1 = crypto.randomBytes(16).toString('hex');
405
+ const nonce2 = crypto.randomBytes(16).toString('hex');
406
+
407
+ const message1 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce1);
408
+ const message2 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce2);
409
+
410
+ expect(() => requireValidHMAC(message1, signingKey)).not.toThrow();
411
+ expect(() => requireValidHMAC(message2, signingKey)).not.toThrow();
412
+ });
413
+
414
+ it('should validate nonce signature correctly', () => {
415
+ const nonce = crypto.randomBytes(16).toString('hex');
416
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
417
+
418
+ const isValid = validateHMAC(message, signingKey);
419
+ expect(isValid).toBe(true);
420
+ });
421
+
422
+ it('should reject message with tampered nonce', () => {
423
+ const nonce = crypto.randomBytes(16).toString('hex');
424
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
425
+
426
+ // Tamper with nonce after signing
427
+ message.nonce = crypto.randomBytes(16).toString('hex');
428
+
429
+ const isValid = validateHMAC(message, signingKey);
430
+ expect(isValid).toBe(false);
431
+ });
432
+
433
+ it('should track nonce statistics correctly', () => {
434
+ clearNonces();
435
+
436
+ const nonce1 = crypto.randomBytes(16).toString('hex');
437
+ const nonce2 = crypto.randomBytes(16).toString('hex');
438
+
439
+ const message1 = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce1);
440
+ const message2 = createSignedMessage('fs.writeFile', { path: '/test.txt' }, undefined, undefined, nonce2);
441
+
442
+ requireValidHMAC(message1, signingKey);
443
+ requireValidHMAC(message2, signingKey);
444
+
445
+ const stats = getNonceStats();
446
+ expect(stats.total).toBe(2);
447
+ expect(stats.active).toBe(2);
448
+ });
449
+
450
+ it('should prevent replay flood attack', () => {
451
+ const nonce = crypto.randomBytes(16).toString('hex');
452
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
453
+
454
+ // First request succeeds
455
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
456
+
457
+ // Flood with replays (all should fail)
458
+ for (let i = 0; i < 10; i++) {
459
+ const replayMessage = createSignedMessage('fs.readFile', { path: '/test.txt' }, message.timestamp, undefined, nonce);
460
+ expect(() => requireValidHMAC(replayMessage, signingKey)).toThrow(
461
+ expect.objectContaining({
462
+ code: ErrorCode.HMAC_VALIDATION_FAILED,
463
+ message: expect.stringContaining('Duplicate nonce'),
464
+ })
465
+ );
466
+ }
467
+ });
468
+
469
+ it('should handle concurrent requests with different nonces', () => {
470
+ const nonces = Array.from({ length: 100 }, () => crypto.randomBytes(16).toString('hex'));
471
+
472
+ // All should succeed (different nonces)
473
+ for (const nonce of nonces) {
474
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
475
+ expect(() => requireValidHMAC(message, signingKey)).not.toThrow();
476
+ }
477
+
478
+ const stats = getNonceStats();
479
+ expect(stats.total).toBe(100);
480
+ expect(stats.active).toBe(100);
481
+ });
482
+
483
+ it('should clean up nonces when map grows too large', () => {
484
+ clearNonces();
485
+
486
+ // Add old nonces (expired)
487
+ const oldTimestamp = Date.now() - 3 * 60 * 1000; // 3 minutes ago
488
+ for (let i = 0; i < 5; i++) {
489
+ const nonce = `old-nonce-${i}`;
490
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, oldTimestamp, undefined, nonce);
491
+ // These will fail due to old timestamp, but that's ok for this test
492
+ try {
493
+ requireValidHMAC(message, signingKey);
494
+ } catch {}
495
+ }
496
+
497
+ // Add many new nonces to trigger cleanup
498
+ for (let i = 0; i < 10001; i++) {
499
+ const nonce = `new-nonce-${i}`;
500
+ const message = createSignedMessage('fs.readFile', { path: '/test.txt' }, undefined, undefined, nonce);
501
+ requireValidHMAC(message, signingKey);
502
+ }
503
+
504
+ // Emergency cleanup should have been triggered
505
+ const stats = getNonceStats();
506
+ expect(stats.active).toBeLessThanOrEqual(10001);
507
+ });
508
+ });
509
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Firebase JWT authentication with public key verification
3
+ */
4
+
5
+ import jwt from 'jsonwebtoken';
6
+ import fetch from 'node-fetch';
7
+ import { JWTPayload, ErrorCode, createRPCError } from '../types.js';
8
+
9
+ const FIREBASE_KEYS_URL =
10
+ 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com';
11
+
12
+ interface PublicKeysCache {
13
+ keys: { [kid: string]: string };
14
+ expiresAt: number;
15
+ }
16
+
17
+ let publicKeysCache: PublicKeysCache | null = null;
18
+
19
+ /**
20
+ * Fetch Firebase public keys with caching
21
+ */
22
+ async function getFirebasePublicKeys(): Promise<{ [kid: string]: string }> {
23
+ const now = Date.now();
24
+
25
+ // Return cached keys if still valid
26
+ if (publicKeysCache && publicKeysCache.expiresAt > now) {
27
+ return publicKeysCache.keys;
28
+ }
29
+
30
+ // Fetch new keys
31
+ const response = await fetch(FIREBASE_KEYS_URL);
32
+ if (!response.ok) {
33
+ throw new Error(`Failed to fetch Firebase public keys: ${response.statusText}`);
34
+ }
35
+
36
+ const keys = await response.json() as { [kid: string]: string };
37
+
38
+ // Parse cache-control header for expiration
39
+ const cacheControl = response.headers.get('cache-control');
40
+ let maxAge = 3600; // Default: 1 hour
41
+
42
+ if (cacheControl) {
43
+ const match = cacheControl.match(/max-age=(\d+)/);
44
+ if (match) {
45
+ maxAge = parseInt(match[1], 10);
46
+ }
47
+ }
48
+
49
+ // Cache the keys
50
+ publicKeysCache = {
51
+ keys,
52
+ expiresAt: now + maxAge * 1000,
53
+ };
54
+
55
+ return keys;
56
+ }
57
+
58
+ /**
59
+ * Verify Firebase JWT token
60
+ */
61
+ export async function verifyFirebaseToken(
62
+ token: string,
63
+ firebaseProjectId: string,
64
+ allowedUids: string[]
65
+ ): Promise<JWTPayload> {
66
+ try {
67
+ // Fetch public keys
68
+ const publicKeys = await getFirebasePublicKeys();
69
+
70
+ // Decode token header to get kid
71
+ const decoded = jwt.decode(token, { complete: true });
72
+ if (!decoded || typeof decoded === 'string') {
73
+ throw createRPCError(
74
+ ErrorCode.AUTHENTICATION_FAILED,
75
+ 'Invalid token format'
76
+ );
77
+ }
78
+
79
+ const kid = decoded.header.kid;
80
+ if (!kid || !publicKeys[kid]) {
81
+ throw createRPCError(
82
+ ErrorCode.AUTHENTICATION_FAILED,
83
+ 'Invalid token key ID'
84
+ );
85
+ }
86
+
87
+ // Verify token signature and claims
88
+ const publicKey = publicKeys[kid];
89
+ const payload = jwt.verify(token, publicKey, {
90
+ algorithms: ['RS256'],
91
+ audience: firebaseProjectId,
92
+ issuer: `https://securetoken.google.com/${firebaseProjectId}`,
93
+ }) as JWTPayload;
94
+
95
+ // Validate UID is in allowed list (if list is not empty)
96
+ if (allowedUids.length > 0 && !allowedUids.includes(payload.sub)) {
97
+ throw createRPCError(
98
+ ErrorCode.UID_NOT_AUTHORIZED,
99
+ `UID not authorized: ${payload.sub}`,
100
+ { uid: payload.uid }
101
+ );
102
+ }
103
+
104
+ return payload;
105
+ } catch (error: any) {
106
+ // Handle JWT errors
107
+ if (error.name === 'TokenExpiredError') {
108
+ throw createRPCError(
109
+ ErrorCode.JWT_EXPIRED,
110
+ 'JWT token expired',
111
+ { expiredAt: error.expiredAt }
112
+ );
113
+ }
114
+
115
+ if (error.name === 'JsonWebTokenError') {
116
+ throw createRPCError(
117
+ ErrorCode.AUTHENTICATION_FAILED,
118
+ `JWT verification failed: ${error.message}`
119
+ );
120
+ }
121
+
122
+ // Re-throw if already an RPC error
123
+ if (error.code && error.message) {
124
+ throw error;
125
+ }
126
+
127
+ // Generic error
128
+ throw createRPCError(
129
+ ErrorCode.AUTHENTICATION_FAILED,
130
+ `Authentication failed: ${error.message || 'Unknown error'}`
131
+ );
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Clear public keys cache (for testing)
137
+ */
138
+ export function clearPublicKeysCache(): void {
139
+ publicKeysCache = null;
140
+ }